Compare commits
389 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b7d399ea | |||
| ffe53e91c0 | |||
| aeb1b12afe | |||
| fe81ec7ee7 | |||
| f0ecc0fd3d | |||
| 03b75fb683 | |||
| dc93621547 | |||
| 7ab6f79e56 | |||
| 9e48f21c8d | |||
| 10e6de8df4 | |||
| 00fbdec6f1 | |||
| 730903c383 | |||
| 5fc9455908 | |||
| 28e0ad68fc | |||
| ca48d702a7 | |||
| 812b9ed075 | |||
| 93a0595652 | |||
| 8a357e8cab | |||
| 22e12977f8 | |||
| 732cf02317 | |||
| 6dea75651b | |||
| 6cff252813 | |||
| 0d06ca6a04 | |||
| a3f875270b | |||
| 00a1312ee3 | |||
| 1195faca0f | |||
| a39f255e52 | |||
| cac2460153 | |||
| 894da4ef9b | |||
| a705b794fd | |||
| 37da6885ee | |||
| 219018b1d9 | |||
| 4b780fd6d2 | |||
| 1faa22897b | |||
| 7e1899d8ff | |||
| 878c668696 | |||
| 8c94fbf299 | |||
| 4ece09368e | |||
| 0f135f4024 | |||
| eac5de0d4a | |||
| 399858d09b | |||
| 7694d8a241 | |||
| 9bd406c5b1 | |||
| d23d28afd8 | |||
| 0a6b2d844f | |||
| be325d31b6 | |||
| e08c1880c6 | |||
| 5f9f6f01c5 | |||
| 65388238c3 | |||
| cdce46b6df | |||
| 8320b54332 | |||
| e8ef0921ad | |||
| c4736fae70 | |||
| 1748f49ee6 | |||
| c32641ec34 | |||
| 53ebe28b14 | |||
| 465686dbaf | |||
| 58604c1e5a | |||
| 3e4f055a4a | |||
| 92586d7466 | |||
| 94c90b8a7c | |||
| f866baa37a | |||
| da72c17cd0 | |||
| 1c5c4af477 | |||
| 83d5ad213b | |||
| ea22c27710 | |||
| 32bf256c84 | |||
| e498e7668e | |||
| f58fbf9d6d | |||
| 5e56c97bf9 | |||
| 000c459d76 | |||
| 307f65c898 | |||
| 5db54adfb7 | |||
| 19fb9a85fc | |||
| 9d48ebbad9 | |||
| 077b5257e8 | |||
| b84602a5ed | |||
| 43541c1af2 | |||
| 8c4d0fa1a1 | |||
| 16fed7a8ca | |||
| c1009a19fb | |||
| be189cf899 | |||
| 8116d1d4de | |||
| ab698c8903 | |||
| 74b16f2165 | |||
| fafbbe9b3a | |||
| 04b32c3f33 | |||
| 0babcbfac4 | |||
| 2da57d95b7 | |||
| d317c03819 | |||
| 2e187b5e08 | |||
| 064343c1ff | |||
| efc67c63d8 | |||
| a978377501 | |||
| 1bf05a1b87 | |||
| 5e3c993a15 | |||
| 4220bb629f | |||
| ea4602abc7 | |||
| bdd30c238c | |||
| 778d799113 | |||
| 47cd1b6a91 | |||
| 118232e906 | |||
| 64c84fe708 | |||
| cff3c02851 | |||
| 60577d02c7 | |||
| 99749bd9c9 | |||
| b3ca2e5927 | |||
| b71e9e2e80 | |||
| 72a8aacabe | |||
| a0e9cfb40d | |||
| 46707c1149 | |||
| 497efcfa76 | |||
| 1201c248ee | |||
| 395eb31801 | |||
| b27edd9818 | |||
| 51c43e9893 | |||
| ec2019430e | |||
| 05174e93ad | |||
| e8ec94bfa8 | |||
| c6642e028c | |||
| 6d5a5f46b9 | |||
| e651eb86ca | |||
| 84171da29b | |||
| d463ec689b | |||
| 416a82058b | |||
| 6f2324ad1b | |||
| c19f13edc1 | |||
| 390b137e0b | |||
| 0c7d4a83f1 | |||
| 25112cb03a | |||
| 5ade4b35a0 | |||
| 00067c56d4 | |||
| 620566490b | |||
| 7245f4cc69 | |||
| 575fecb163 | |||
| 4120b1abfa | |||
| 801d3739fc | |||
| 92bdf14508 | |||
| fa25a82693 | |||
| 1684f3e0cb | |||
| a31f807da8 | |||
| 6d92b49f3d | |||
| 11cdf975bc | |||
| 7bacc28e3b | |||
| 4bbb71d434 | |||
| 8da153a476 | |||
| 1189325638 | |||
| e93459ae55 | |||
| b1fcf51546 | |||
| 75716368bb | |||
| ec15e2bb63 | |||
| 9a3e030ce7 | |||
| 4f5894ef4a | |||
| e0b5610d90 | |||
| bc34519a26 | |||
| dc69bfa8c7 | |||
| cf7c5fc23a | |||
| 16d5b80657 | |||
| 169eabd800 | |||
| 62dcae48bb | |||
| b8f24d4b67 | |||
| 6d9e0008b8 | |||
| b9d41f9ac0 | |||
| f724f450f2 | |||
| cc447c5fb0 | |||
| b3f0f89d16 | |||
| 8dd153fb53 | |||
| 89bfb28019 | |||
| 1215e47297 | |||
| 4016899179 | |||
| 1cad9b83fb | |||
| 780ee76dc3 | |||
| 4d8e17f59f | |||
| 21c27e22da | |||
| 2e978089b5 | |||
| 803fe7147e | |||
| 6490364a5d | |||
| 2ad773952e | |||
| 138e71107e | |||
| bde399f589 | |||
| 8a43602363 | |||
| 5a56790fd4 | |||
| 892f681174 | |||
| 997b5420ee | |||
| 643bbe3af2 | |||
| f85f0f19b9 | |||
| 3f9a1bafa0 | |||
| 6f72a03ecf | |||
| 5b8e4c634e | |||
| 70c3f7162e | |||
| cb9199431a | |||
| cba9fe1e7b | |||
| baa49e77eb | |||
| b015462e50 | |||
| c9f1fc1bea | |||
| 1177886271 | |||
| 0e1c56dd18 | |||
| eefe237dff | |||
| 8d4e004177 | |||
| 1c63e2b856 | |||
| 3040cbf49a | |||
| 1a124a2571 | |||
| 26c8c3f3b9 | |||
| 50d814ebf6 | |||
| 7fc20876dd | |||
| 183108400e | |||
| 3055d34979 | |||
| 747f5f5f42 | |||
| 4db9409b94 | |||
| 8693bb29ca | |||
| 524d8ca41d | |||
| 3c77eca689 | |||
| 307f74b1d9 | |||
| 0c29393f3b | |||
| af3726cb91 | |||
| 0d40fd92f0 | |||
| 0f55d84355 | |||
| eafa889827 | |||
| 60cd880c2e | |||
| ea8de69863 | |||
| 11ef26e878 | |||
| 2770ebf958 | |||
| 536ca56c24 | |||
| d4407d3492 | |||
| 81766b4aba | |||
| e1cc040984 | |||
| cdda9d7ff7 | |||
| 9b6afe180d | |||
| ae22bb2e81 | |||
| f2e1211661 | |||
| 489bbbbec6 | |||
| d992a359a6 | |||
| c3e5d216ab | |||
| fee9cda5ba | |||
| 0c3a851e7d | |||
| 56ceb92c2b | |||
| fb96792821 | |||
| b512500c9c | |||
| b97a41f7ad | |||
| 2706ed7162 | |||
| 2494160c96 | |||
| dec253d860 | |||
| df9d863abb | |||
| 73dd885741 | |||
| 9cd6a37b05 | |||
| dfdb2af59f | |||
| 772e868a59 | |||
| 748589226e | |||
| 3b4f3b5efd | |||
| 791ba64dba | |||
| 8007e644d6 | |||
| 12cedd9e40 | |||
| 0d1e6543d1 | |||
| e0ab36ff61 | |||
| 6231baefe9 | |||
| 4c1da2308e | |||
| 4cde6b82ea | |||
| 4e6dcd6ff4 | |||
| 4f6e3c3711 | |||
| 863627bb42 | |||
| 07ebdaf77f | |||
| 642d31dc31 | |||
| 1235abadd0 | |||
| 25176c5a5f | |||
| 073ff88fa4 | |||
| 03dda7b947 | |||
| be859ad859 | |||
| d5f25c6f76 | |||
| 82a84cec97 | |||
| 41377757ec | |||
| 140f7d5a93 | |||
| 0e9bb1de0f | |||
| 9e6f879d16 | |||
| 28c326a34a | |||
| bc6251f22a | |||
| 6868c239a5 | |||
| b327059400 | |||
| 57495d36d8 | |||
| 89c1892013 | |||
| 7b4e16a9a1 | |||
| d6631fb9e1 | |||
| 06f3eaaaf7 | |||
| 1671abb5ee | |||
| d4af0436be | |||
| 9d9baa8857 | |||
| ff476ad406 | |||
| 0409677626 | |||
| 31af05bf70 | |||
| 0434bc6961 | |||
| 194ad4a532 | |||
| c19896beae | |||
| 66311a1c4c | |||
| 6aa7910af1 | |||
| cd47abddc6 | |||
| 9788070a16 | |||
| 46531ce4fa | |||
| 400ee1c6f2 | |||
| 8a3d3d1d34 | |||
| 3c41759cf3 | |||
| 336645d8e2 | |||
| 11ae07d69f | |||
| 81ed440404 | |||
| 3c368310ad | |||
| 7543c0a865 | |||
| ff725e0119 | |||
| ab24b528d1 | |||
| feab24ed2f | |||
| b441fb3953 | |||
| 812e672c11 | |||
| 2995f8b37e | |||
| 339de1ac9a | |||
| 22cab746e6 | |||
| f99efa9ddf | |||
| 10e00e6ef6 | |||
| d53d1a3ed3 | |||
| 5a04c33f13 | |||
| b6a123a90f | |||
| 5756fa06d7 | |||
| d1dbf3af62 | |||
| c1a4a44024 | |||
| 965303b6b0 | |||
| 6b424e2dcc | |||
| 3fd66d1138 | |||
| 740a29ce04 | |||
| 39160d654b | |||
| f93bfad3ce | |||
| 63c4c1c228 | |||
| a3d5ee0d1b | |||
| 8b067ece84 | |||
| 82dde818e6 | |||
| dd1e5c836c | |||
| 64cfdb32a9 | |||
| f793eb83c5 | |||
| f8d54d3813 | |||
| 318f8dd940 | |||
| bf8478a43f | |||
| e97e927ca3 | |||
| 6f847b9033 | |||
| 2802a018b9 | |||
| 5555e95e48 | |||
| 6ce9833e66 | |||
| 99c5ad3979 | |||
| bcb1820095 | |||
| 6e75713589 | |||
| 074cfa70fb | |||
| 3cb0ad5c86 | |||
| da9e7dd5c7 | |||
| 104096471d | |||
| 83c5561edb | |||
| 9d1e17cfc0 | |||
| 6e14852f55 | |||
| 34a8de221f | |||
| 51c33ebec0 | |||
| e4d45f391c | |||
| 23615c7309 | |||
| 1c774096ab | |||
| d70eeb6a0b | |||
| 31b551e34a | |||
| 914d65574b | |||
| 231c9ffc6e | |||
| 9e173ecc35 | |||
| 367fd7802b | |||
| 267049b87f | |||
| ed4d955507 | |||
| 32fed82772 | |||
| 90dafccec6 | |||
| 7d119a8c24 | |||
| d0fbf44fa0 | |||
| dc1042e736 | |||
| 5fbe1ef9d4 | |||
| 9d808cd447 | |||
| 5415c62d49 | |||
| e69a8fa4e6 | |||
| 80b25115a3 | |||
| 5bcbec82f4 | |||
| ab491971e4 | |||
| 2b9c3fb469 | |||
| 83abdd4169 | |||
| 7bdf22d188 | |||
| 99830d1a55 | |||
| 85660d7b9d | |||
| 5fa3d6a83c | |||
| 17e9bce1ae | |||
| 854fa1c7e9 | |||
| 02a3a1ea9f | |||
| 1db90cce48 | |||
| 0748ff012d | |||
| 5bb601f869 | |||
| 835c2115f2 |
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
packages
|
||||||
|
vendor
|
||||||
|
tests
|
||||||
|
.DS_Store
|
||||||
|
vapor.yml
|
||||||
|
.vapor
|
||||||
|
storage/app/livewire-tmp/*
|
||||||
|
storage/app/public/profile-photos/*
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/testing/*
|
||||||
|
storage/framework/views/*
|
||||||
|
storage/logs/*
|
||||||
+41
-41
@@ -1,25 +1,49 @@
|
|||||||
APP_NAME=Investbrain
|
# Generate a secure key using `openssl rand -base64 32`
|
||||||
APP_ENV=production
|
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
|
||||||
APP_TIMEZONE=UTC
|
# Port for NGINX to listen on
|
||||||
APP_URL=http://localhost
|
|
||||||
ASSET_URL="${APP_URL}"
|
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
SELF_HOSTED=true
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
# Used internally to generate absolute links
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_URL="http://localhost:${APP_PORT}"
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
# Webroot for static assets (css, js, images, etc)
|
||||||
|
ASSET_URL="${APP_URL}"
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
# Enables or disables new user registration
|
||||||
|
REGISTRATION_ENABLED=true
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
# Enable or disable AI chat feature
|
||||||
LOG_STACK=single
|
AI_CHAT_ENABLED=false
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
|
||||||
LOG_LEVEL=debug
|
# API key for OpenAI (for Llama support, see docs)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_ORGANIZATION=
|
||||||
|
|
||||||
|
# Market data provider to use (comma separated list)
|
||||||
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
|
ALPHAVANTAGE_API_KEY=
|
||||||
|
FINNHUB_API_KEY=
|
||||||
|
|
||||||
|
# Cadence to refresh market data (in minutes)
|
||||||
|
MARKET_DATA_REFRESH=30
|
||||||
|
DAILY_CHANGE_TIME=
|
||||||
|
|
||||||
|
#### Advanced configurations ####
|
||||||
|
ENABLED_LOGIN_PROVIDERS=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
FACEBOOK_CLIENT_ID=
|
||||||
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
CACHE_STORE=redis
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=investbrain-mysql
|
DB_HOST=investbrain-mysql
|
||||||
@@ -28,24 +52,7 @@ DB_DATABASE=investbrain
|
|||||||
DB_USERNAME=investbrain
|
DB_USERNAME=investbrain
|
||||||
DB_PASSWORD=investbrain
|
DB_PASSWORD=investbrain
|
||||||
|
|
||||||
SESSION_DRIVER=redis
|
REDIS_HOST=investbrain-redis
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=redis
|
|
||||||
|
|
||||||
CACHE_STORE=redis
|
|
||||||
CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=predis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PATH=/tmp/database_server.sock
|
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
@@ -58,15 +65,8 @@ MAIL_ENCRYPTION=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
MARKET_DATA_PROVIDER=yahoo
|
|
||||||
MARKET_DATA_REFRESH=30
|
|
||||||
ALPHAVANTAGE_API_KEY=
|
|
||||||
FINNHUB_API_KEY=
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build and push Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04 #ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Increase swap space
|
||||||
|
run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GIT_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: extract-version
|
||||||
|
run: |
|
||||||
|
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
investbrainapp/investbrain:latest
|
||||||
|
investbrainapp/investbrain:${{ env.version }}
|
||||||
|
ghcr.io/investbrainapp/investbrain:latest
|
||||||
|
ghcr.io/investbrainapp/investbrain:${{ env.version }}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/packages
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
@@ -19,3 +20,5 @@ yarn-error.log
|
|||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
vapor.yml
|
||||||
|
.vapor
|
||||||
|
|||||||
@@ -1,38 +1,60 @@
|
|||||||
|
|
||||||
|
|
||||||
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p>
|
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p>
|
||||||
|
|
||||||
|
[](https://github.com/investbrainapp/investbrain/)
|
||||||
|
[](https://github.com/investbrainapp/investbrain/)
|
||||||
|
[](https://github.com/investbrainapp/investbrain/issues)
|
||||||
|
[](https://hub.docker.com/r/investbrainapp/investbrain/)
|
||||||
|
|
||||||
|
|
||||||
## About Investbrain
|
## About Investbrain
|
||||||
|
|
||||||
Investbrain helps you manage and track the performance of your investments.
|
Investbrain is a smart open-source investment tracker that helps you manage, track, and make informed decisions about your investments.
|
||||||
|
|
||||||
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/screenshot.png" width="100%" alt="Investbrain Screenshot"></a></p>
|
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/screenshot.png" width="100%" alt="Investbrain Screenshot"></a></p>
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
- [Under the hood](#under-the-hood)
|
||||||
|
- [Install (self hosting)](#self-hosting)
|
||||||
|
- [Chat with your holdings](#chat-with-your-holdings)
|
||||||
|
- [Market data providers](#market-data-providers)
|
||||||
|
- [Import / Export](#import--export)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Updating](#updating)
|
||||||
|
- [Command line utilities](#command-line-utilities)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Testing](#testing)
|
||||||
|
|
||||||
## Under the hood
|
## Under the hood
|
||||||
|
|
||||||
Investbrain is a Laravel PHP web application that leverages Livewire, Mary UI, and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! Finally, of course we have robust support for i18n, a11y, and dark mode.
|
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||||
|
|
||||||
## Installation
|
## Self hosting
|
||||||
|
|
||||||
For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which downloads all the necessary dependencies and seamlessly builds everything you need to get started quickly!
|
For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which uses the official Investbrain Docker image and includes all the necessary dependencies to seamlessly build everything you need to get started quickly!
|
||||||
|
|
||||||
Before getting started, you should already have the following installed on your machine: [Docker Engine](https://docs.docker.com/engine/install/), [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git), and a wild sense of adventure.
|
Before getting started, you should already have [Docker Engine](https://docs.docker.com/engine/install/) installed on your machine.
|
||||||
|
|
||||||
Ready? Let's get started!
|
Ready? Let's get started!
|
||||||
|
|
||||||
First, you can clone this repository:
|
**1. Download copy of Docker Compose file**
|
||||||
|
|
||||||
|
Grab a copy of the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) using `wget`, `curl` or similar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/investbrainapp/investbrain.git && cd investbrain
|
curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, build the Docker image and bring up the container (this will take a few minutes):
|
**2. Set your environment**
|
||||||
|
|
||||||
```bash
|
Adjust the `environment` properties in the compose file to your preferences.
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
In the previous step, all of the default configurations are set automatically. This includes creating a .env file and setting the required Laravel `APP_KEY`.
|
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
|
||||||
|
|
||||||
If everything worked as expected, you should now be able to access Investbrain in the browser at. You should create an account by visiting:
|
**3. Run `docker compose up`**
|
||||||
|
|
||||||
|
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
http://localhost:8000/register
|
http://localhost:8000/register
|
||||||
@@ -40,21 +62,31 @@ http://localhost:8000/register
|
|||||||
|
|
||||||
Congrats! You've just installed Investbrain!
|
Congrats! You've just installed Investbrain!
|
||||||
|
|
||||||
|
## Chat with your holdings
|
||||||
|
|
||||||
|
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
|
||||||
|
|
||||||
|
When self-hosting, you can enable the chat assistant by configuring your OpenAI Secret Key and Organization ID in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file. Navigate to OpenAI to [create your keys](https://platform.openai.com/api-keys).
|
||||||
|
|
||||||
|
If you are self-hosting your own large language models ("LLMs") that expose an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you can update the `OPENAI_BASE_URI` configuration to your self-hosted instance. Ensure you also update the `OPENAI_MODEL` to an available model.
|
||||||
|
|
||||||
|
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
|
||||||
|
|
||||||
## Market data providers
|
## Market data providers
|
||||||
|
|
||||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
You can specify the provider you want to use in your .env file:
|
You can specify the market data provider you want to use in your environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also use Investbrain's built-in fallback mechanism to ensure reliable data access, even if a provider fails. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully.
|
You can also use Investbrain's built-in fallback mechanism to ensure reliable data access. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully.
|
||||||
|
|
||||||
Your selected providers should be listed in your .env file. Each should be separated by a comma:
|
Your selected providers should be listed in your environment variables. Each should be separated by a comma:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
||||||
@@ -64,7 +96,7 @@ In the above example, Yahoo Finance will be attempted first and the Alpha Vantag
|
|||||||
|
|
||||||
### Custom providers
|
### Custom providers
|
||||||
|
|
||||||
If you wish to create your own market data provider, you can create your own implementation of the [MarketDataInterface](https://github.com/investbrainapp/investbrain/blob/main/app/Interfaces/MarketData/MarketDataInterface.php). You can refer to any existing market data implementation as an examples.
|
If you wish to create your own market data provider, you can create your own implementation of the [MarketDataInterface](https://github.com/investbrainapp/investbrain/blob/main/app/Interfaces/MarketData/MarketDataInterface.php). You can refer to any existing market data implementation as an example.
|
||||||
|
|
||||||
Once you've created your market data implementation, be sure add your custom provider to the Investbrain configuration file, under the interfaces section:
|
Once you've created your market data implementation, be sure add your custom provider to the Investbrain configuration file, under the interfaces section:
|
||||||
|
|
||||||
@@ -85,36 +117,58 @@ MARKET_DATA_PROVIDER=yahoo,alphavantage,custom_provider
|
|||||||
|
|
||||||
Feel free to submit a PR with any custom providers you create.
|
Feel free to submit a PR with any custom providers you create.
|
||||||
|
|
||||||
|
## Import / Export
|
||||||
|
|
||||||
|
Investbrain includes a convenient feature which allows you to maintain the portability of your portfolios and transaction data.
|
||||||
|
|
||||||
|
### Import
|
||||||
|
|
||||||
|
Imports are "upserted" to the database. If the record does not already exist in the database, the record will be created. However, when a portfolio or transaction exists (i.e. the record's ID matches an existing record), the record will be updated. This way, you can simultaneously create new records, but also bulk update records.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
Exporting your portfolios and transactions is a convenient way to back-up your Investbrain data. It is also a convenient way to maintain portability of *your* data.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
There are several optional configurations available when installing using the recommended [Docker method](#Installation). These options are configurable using an environment file. Changes can be made in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file before installation.
|
There are several optional configurations available when installing using the recommended [Docker method](#self-hosting). These options are configurable using an environment file. Configurations can be added to your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file or to the `environment` property in the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file.
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
| ------------- | ------------- | ------------- |
|
| ------------- | ------------- | ------------- |
|
||||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||||
|
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
|
||||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
||||||
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
|
||||||
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||||
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
||||||
|
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||||
|
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||||
|
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||||
|
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` |
|
||||||
|
| OPENAI_ORGANIZATION | OpenAI org id (required for AI chat) | `null` |
|
||||||
|
| OPENAI_MODEL | The selected LLM used for AI chat | gpt-4o |
|
||||||
|
| OPENAI_BASE_URI | The URI for your self-hosted LLM | api.openai.com/v1 |
|
||||||
|
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
|
||||||
|
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
|
||||||
|
|
||||||
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, so if you decide to make any changes to these default configurations, you'll have to restart the Docker containers before your changes take effect.
|
|
||||||
|
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file and are cached during run-time. If change any environment configurations, you'll have to restart the container before your changes take effect.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
To update Investbrain using the recommended [Docker installation](#Installation) method, you just need to stop the running containers:
|
To update Investbrain using the recommended [Docker installation](#self-hosting) method, you just need to stop the running containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose stop
|
docker compose stop
|
||||||
```
|
```
|
||||||
|
|
||||||
Then pull the latest updates from this repository using git:
|
Then pull the latest Docker image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
docker image pull investbrainapp/investbrain:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Then bring the containers back up!
|
Finally bring the containers back up!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up
|
docker compose up
|
||||||
@@ -143,9 +197,42 @@ Just to be safe, we recommend backing up your portfolios before using these comm
|
|||||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you are facing issues with Investbrain, it can be handy to monitor the application's logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it investbrain-app cat storage/logs/laravel.log
|
||||||
|
```
|
||||||
|
or you can live monitor logs using `tail`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it investbrain-app tail -f storage/logs/laravel.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common issues
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
**<summary>Application styling is broken and images are too big</summary>**
|
||||||
|
|
||||||
|
If you're serving Investbrain from a DNS name (e.g. example.com), it's likely that you haven't updated the `ASSET_URL` environment yet. The URL provided there will be used to generate absolute URLs for images, JS, and CSS assets on the front end of the application.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
**<summary>Market data not refreshing on fresh install</summary>**
|
||||||
|
|
||||||
|
If you're unable to refresh market data out of the box (i.e. your market data provider is set to Yahoo), there is a chance Yahoo is being blocked by a firewall or adblocker. Pihole is known to block `fc.yahoo.com` which is the domain used to query Yahoo.
|
||||||
|
|
||||||
|
Once you whitelist `fc.yahoo.com` in pihole, your market data should begin populating!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Investbrain has a complete PHPUnit test suite that creates an in-memory SQLite database and runs any queued jobs synchronously using Laravel's array driver. You can run the entire Investbrain test suite from within the Docker container by running:
|
Investbrain has a robus PHPUnit test suite that creates an in-memory SQLite database and runs any queued jobs synchronously using Laravel's array driver. You can run the entire Investbrain test suite from within the Docker container by running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it investbrain-app php artisan test
|
docker exec -it investbrain-app php artisan test
|
||||||
@@ -169,7 +256,7 @@ We ask that you be kind and polite when interacting with the Investbrain communi
|
|||||||
|
|
||||||
## Security Vulnerabilities
|
## Security Vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability within Investbrain, please create an issue in the [Github repository](https://github.com/investbrainapp/investbrain). All security vulnerabilities will be promptly addressed.
|
If you discover a security vulnerability within Investbrain, please submit your report via [Github](https://github.com/investbrainapp/investbrain/security/advisories/new). All security vulnerabilities will be promptly addressed. We ask that you keep any suspected vulnerabilities private and confidential until they have been appropriately addressed.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 1.1.x | :white_check_mark: |
|
||||||
|
| 1.0.x | :x: |
|
||||||
|
| < 1.0.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Investbrain, please submit your report via [Github](https://github.com/investbrainapp/investbrain/security/advisories/new). All security vulnerabilities will be promptly addressed. We ask that you keep any suspected vulnerabilities private and confidential until they have been appropriately addressed.
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Traits\WithTrimStrings;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
@@ -11,6 +14,12 @@ use Laravel\Jetstream\Jetstream;
|
|||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
use PasswordValidationRules;
|
use PasswordValidationRules;
|
||||||
|
use WithTrimStrings;
|
||||||
|
|
||||||
|
public function trimExceptions()
|
||||||
|
{
|
||||||
|
return ['password'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Traits\WithTrimStrings;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -10,6 +13,8 @@ use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
|||||||
|
|
||||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
{
|
{
|
||||||
|
use WithTrimStrings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and update the given user's profile information.
|
* Validate and update the given user's profile information.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
@@ -38,9 +40,9 @@ class CaptureDailyChange extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
Portfolio::with('holdings.market_data')->get()->each(function($portfolio){
|
Portfolio::with('holdings.market_data')->get()->each(function ($portfolio) {
|
||||||
|
|
||||||
$this->line('Capturing daily change for ' . $portfolio->title);
|
$this->line('Capturing daily change for '.$portfolio->title);
|
||||||
|
|
||||||
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ class CaptureDailyChange extends Command
|
|||||||
|
|
||||||
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
||||||
|
|
||||||
$total_market_value = $portfolio->holdings->sum(function($holding) {
|
$total_market_value = $portfolio->holdings->sum(function ($holding) {
|
||||||
return $holding->market_data->market_value * $holding->quantity;
|
return $holding->market_data->market_value * $holding->quantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ class CaptureDailyChange extends Command
|
|||||||
'total_cost_basis' => $total_cost_basis,
|
'total_cost_basis' => $total_cost_basis,
|
||||||
'total_gain' => $total_market_value - $total_cost_basis,
|
'total_gain' => $total_market_value - $total_cost_basis,
|
||||||
'total_dividends_earned' => $total_dividends,
|
'total_dividends_earned' => $total_dividends,
|
||||||
'realized_gains' => $realized_gains
|
'realized_gains' => $realized_gains,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
|
||||||
use App\Models\Dividend;
|
use App\Models\Dividend;
|
||||||
|
use App\Models\Holding;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RefreshDividendData extends Command
|
class RefreshDividendData extends Command
|
||||||
@@ -13,7 +15,9 @@ class RefreshDividendData extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'refresh:dividend-data';
|
protected $signature = 'refresh:dividend-data
|
||||||
|
{--force : Refresh all holdings}
|
||||||
|
{--user= : Limit refresh to user\'s holdings}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -39,11 +43,19 @@ class RefreshDividendData extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']);
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
|
if (! ($this->option('force') ?? false)) {
|
||||||
|
$holdings->where('quantity', '>', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
$holdings->myHoldings($this->option('user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holdings->get(['symbol']) as $holding) {
|
||||||
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
|
||||||
|
|
||||||
Dividend::refreshDividendData($holding->symbol);
|
Dividend::refreshDividendData($holding->symbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class RefreshMarketData extends Command
|
class RefreshMarketData extends Command
|
||||||
{
|
{
|
||||||
@@ -14,7 +17,8 @@ class RefreshMarketData extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'refresh:market-data
|
protected $signature = 'refresh:market-data
|
||||||
{--force= : Ignore refresh delay}';
|
{--force : Ignore refresh delay}
|
||||||
|
{--user= : Limit refresh to user\'s holdings}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -40,16 +44,25 @@ class RefreshMarketData extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
|
$force = $this->option('force') ?? false;
|
||||||
|
|
||||||
// get all symbols from market data
|
// get all symbols from market data
|
||||||
$holdings = Holding::where('quantity', '>', 0)
|
$holdings = Holding::where('quantity', '>', 0)
|
||||||
->select(['symbol'])
|
->select(['symbol'])
|
||||||
->distinct()
|
->distinct();
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
if ($this->option('user')) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$holdings->myHoldings($this->option('user'));
|
||||||
|
}
|
||||||
|
|
||||||
MarketData::getMarketData($holding->symbol);
|
foreach ($holdings->get() as $holding) {
|
||||||
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
|
try {
|
||||||
|
MarketData::getMarketData($holding->symbol, $force);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Split;
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
|
use App\Models\Split;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RefreshSplitData extends Command
|
class RefreshSplitData extends Command
|
||||||
@@ -14,7 +16,7 @@ class RefreshSplitData extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'refresh:split-data
|
protected $signature = 'refresh:split-data
|
||||||
{--force= : Don\'t ask to confirm.}';
|
{--force : Refresh all holdings}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -40,12 +42,16 @@ class RefreshSplitData extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']);
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
|
if (! ($this->option('force') ?? false)) {
|
||||||
|
$holdings->where('quantity', '>', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holdings->get(['symbol']) as $holding) {
|
||||||
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
|
||||||
|
|
||||||
Split::refreshSplitData($holding->symbol);
|
Split::refreshSplitData($holding->symbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||||
|
|
||||||
use function Laravel\Prompts\search;
|
use function Laravel\Prompts\search;
|
||||||
|
|
||||||
class SyncDailyChange extends Command implements PromptsForMissingInput
|
class SyncDailyChange extends Command implements PromptsForMissingInput
|
||||||
@@ -61,14 +64,14 @@ class SyncDailyChange extends Command implements PromptsForMissingInput
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
||||||
$portfolio = Portfolio::findOrFail($this->argument('portfolio_id'));
|
$portfolio = Portfolio::findOrFail($this->argument('portfolio_id'));
|
||||||
|
|
||||||
$this->line('Syncing daily change history... This may take a moment.');
|
$this->line('Syncing daily change history... This may take a moment.');
|
||||||
|
|
||||||
$portfolio->syncDailyChanges();
|
$portfolio->syncDailyChanges();
|
||||||
|
|
||||||
$this->line('Awesome! Daily change history for '. $portfolio->title .' has been completed.');
|
$this->line('Awesome! Daily change history for '.$portfolio->title.' has been completed.');
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -12,7 +14,8 @@ class SyncHoldingData extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'sync:holdings';
|
protected $signature = 'sync:holdings
|
||||||
|
{--user= : Limit refresh to user\'s holdings}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -39,10 +42,14 @@ class SyncHoldingData extends Command
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
// get all holdings
|
// get all holdings
|
||||||
$holdings = Holding::get();
|
$holdings = Holding::query();
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
if ($this->option('user')) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$holdings->myHoldings($this->option('user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holdings->get() as $holding) {
|
||||||
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
$holding->syncTransactionsAndDividends();
|
$holding->syncTransactionsAndDividends();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports;
|
namespace App\Exports;
|
||||||
|
|
||||||
use App\Exports\Sheets\DailyChangesSheet;
|
use App\Exports\Sheets\DailyChangesSheet;
|
||||||
@@ -14,18 +16,14 @@ class BackupExport implements WithMultipleSheets
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
)
|
) {}
|
||||||
{ }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function sheets(): array
|
public function sheets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new PortfoliosSheet($this->empty),
|
new PortfoliosSheet($this->empty),
|
||||||
new TransactionsSheet($this->empty),
|
new TransactionsSheet($this->empty),
|
||||||
new DailyChangesSheet($this->empty)
|
new DailyChangesSheet($this->empty),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\DailyChange;
|
use App\Models\DailyChange;
|
||||||
@@ -11,7 +13,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
@@ -21,23 +23,20 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Total Market Value',
|
'Total Market Value',
|
||||||
'Total Cost Basis',
|
'Total Cost Basis',
|
||||||
'Total Gain',
|
'Total Gain',
|
||||||
'Total Dividends',
|
'Total Dividends Earned',
|
||||||
'Realized Gains',
|
'Realized Gains',
|
||||||
'Annotation'
|
'Annotation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Daily Changes';
|
return 'Daily Changes';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
@@ -11,8 +13,8 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -21,21 +23,18 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Notes',
|
'Notes',
|
||||||
'Wishlist',
|
'Wishlist',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated'
|
'Updated',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : Portfolio::myPortfolios()->get();
|
return $this->empty ? collect() : Portfolio::myPortfolios()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Portfolios';
|
return 'Portfolios';
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
|
||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
|
||||||
class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
@@ -24,23 +26,21 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Cost Basis',
|
'Cost Basis',
|
||||||
'Sale Price',
|
'Sale Price',
|
||||||
'Split',
|
'Split',
|
||||||
|
'Reinvested Dividend',
|
||||||
'Date',
|
'Date',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated'
|
'Updated',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Transactions';
|
return 'Transactions';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\HoldingRequest;
|
||||||
|
use App\Http\Resources\HoldingResource;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class HoldingController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
|
||||||
|
$filters->setQuery(Holding::query());
|
||||||
|
$filters->setScopes(['myHoldings']);
|
||||||
|
$filters->setEagerRelations(['market_data', 'transactions']);
|
||||||
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
|
return HoldingResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Portfolio $portfolio, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
Gate::authorize('readOnly', $portfolio);
|
||||||
|
|
||||||
|
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
|
||||||
|
|
||||||
|
return HoldingResource::make($holding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(HoldingRequest $request, Portfolio $portfolio, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
|
||||||
|
|
||||||
|
$holding->update($request->validated());
|
||||||
|
|
||||||
|
return HoldingResource::make($holding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Resources\MarketDataResource;
|
||||||
|
use App\Models\MarketData;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MarketDataController extends ApiController
|
||||||
|
{
|
||||||
|
public function show(Request $request, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
return MarketDataResource::make(
|
||||||
|
MarketData::getMarketData($symbol)
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
return response([
|
||||||
|
'message' => 'Symbol '.$symbol.' not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\PortfolioRequest;
|
||||||
|
use App\Http\Resources\PortfolioResource;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class PortfolioController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
$filters->setQuery(Portfolio::query());
|
||||||
|
$filters->setScopes(['myPortfolios']);
|
||||||
|
$filters->setEagerRelations(['users', 'transactions', 'holdings']);
|
||||||
|
$filters->setFilterableRelations(['holdings.symbol']);
|
||||||
|
$filters->setSearchableColumns(['title', 'notes']);
|
||||||
|
|
||||||
|
return PortfolioResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(PortfolioRequest $request)
|
||||||
|
{
|
||||||
|
$portfolio = Portfolio::create($request->validated());
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('readOnly', $portfolio);
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(PortfolioRequest $request, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$portfolio->update($request->validated());
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$portfolio->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\TransactionRequest;
|
||||||
|
use App\Http\Resources\TransactionResource;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class TransactionController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
|
||||||
|
$filters->setQuery(Transaction::query());
|
||||||
|
$filters->setScopes(['myTransactions']);
|
||||||
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
|
return TransactionResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TransactionRequest $request)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $request->portfolio);
|
||||||
|
|
||||||
|
$transaction = Transaction::create($request->validated());
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('readOnly', $transaction->portfolio);
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TransactionRequest $request, Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
|
$transaction->update($request->validated());
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
|
$transaction->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserController extends ApiController
|
||||||
|
{
|
||||||
|
public function me(Request $request)
|
||||||
|
{
|
||||||
|
return UserResource::make($request->user());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\VerifyConnectedAccountNotification;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\MessageBag;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
||||||
|
class ConnectedAccountController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Redirect the user to the GitHub authentication page.
|
||||||
|
*/
|
||||||
|
public function redirectToProvider(string $provider)
|
||||||
|
{
|
||||||
|
$this->validateProvider($provider);
|
||||||
|
|
||||||
|
return Socialite::driver($provider)->redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain the user information from GitHub.
|
||||||
|
*/
|
||||||
|
public function handleProviderCallback(string $provider)
|
||||||
|
{
|
||||||
|
$this->validateProvider($provider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
$providerUser = Socialite::driver($provider)->user();
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
|
||||||
|
return redirect(route('login'))
|
||||||
|
->with('errors', new MessageBag([__('Could not login using :provider. Try again later.', ['provider' => config("services.$provider.name")])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if this account is already linked
|
||||||
|
$connected_account = ConnectedAccount::firstOrNew([
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_id' => $providerUser->id,
|
||||||
|
], [
|
||||||
|
'token' => $providerUser->token,
|
||||||
|
'secret' => $providerUser->tokenSecret,
|
||||||
|
'refresh_token' => $providerUser->refreshToken,
|
||||||
|
'expires_at' => $providerUser->expiresIn,
|
||||||
|
'verified_at' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// already linked and verified, let's go login!
|
||||||
|
if (
|
||||||
|
$connected_account->exists
|
||||||
|
&& ! is_null($connected_account->verified_at)
|
||||||
|
) {
|
||||||
|
|
||||||
|
Auth::login($connected_account->user, true);
|
||||||
|
|
||||||
|
return redirect(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// new user, let's create one
|
||||||
|
if (! $user = User::where('email', $providerUser->email)->first()) {
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $providerUser->name,
|
||||||
|
'email' => $providerUser->email,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connected_account->user_id = $user->id;
|
||||||
|
$connected_account->verified_at = now();
|
||||||
|
$connected_account->save();
|
||||||
|
|
||||||
|
Auth::login($user, true);
|
||||||
|
|
||||||
|
return redirect(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// email exists already, send verification link
|
||||||
|
$connected_account->user_id = $user->id;
|
||||||
|
$connected_account->save();
|
||||||
|
|
||||||
|
$user->notify(new VerifyConnectedAccountNotification($connected_account->id));
|
||||||
|
|
||||||
|
return redirect(route('login'))
|
||||||
|
->with('status', __(
|
||||||
|
'Account already exists. Check your email to connect your :provider account.',
|
||||||
|
['provider' => config("services.$provider.name")]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateProvider($provider): void
|
||||||
|
{
|
||||||
|
if (! in_array($provider, explode(',', config('services.enabled_login_providers')))) {
|
||||||
|
|
||||||
|
throw new Exception('Please provide a valid social provider.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(ConnectedAccount $connected_account)
|
||||||
|
{
|
||||||
|
if (! $connected_account->verified_at) {
|
||||||
|
|
||||||
|
// mark request as verified
|
||||||
|
$connected_account->verified_at = now();
|
||||||
|
$connected_account->save();
|
||||||
|
|
||||||
|
// mark user as verified
|
||||||
|
$connected_account->user->email_verified_at = now();
|
||||||
|
$connected_account->user->save();
|
||||||
|
|
||||||
|
Auth::login($connected_account->user, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('dashboard'))->with('toast', json_encode([
|
||||||
|
'toast' => [
|
||||||
|
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
|
||||||
|
'description' => null,
|
||||||
|
'css' => 'alert-success',
|
||||||
|
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
|
||||||
|
'position' => 'toast-top toast-end',
|
||||||
|
'timeout' => '5000',
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -15,15 +17,16 @@ class DashboardController extends Controller
|
|||||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->tags(['metrics', 'dashboard', $user->id])->remember(
|
$metrics = cache()->remember(
|
||||||
'dashboard-metrics-' . $user->id,
|
'dashboard-metrics-'.$user->id,
|
||||||
10,
|
10,
|
||||||
function () {
|
function () {
|
||||||
return
|
return
|
||||||
Holding::query()
|
Holding::query()
|
||||||
->myHoldings()
|
->myHoldings()
|
||||||
->withPortfolioMetrics()
|
->withoutWishlists()
|
||||||
->first();
|
->withPortfolioMetrics()
|
||||||
|
->first();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -8,27 +10,23 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class HoldingController extends Controller
|
class HoldingController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Request $request, Portfolio $portfolio, String $symbol)
|
public function show(Request $request, Portfolio $portfolio, string $symbol)
|
||||||
{
|
{
|
||||||
$holding = Holding::with([
|
$holding = Holding::with([
|
||||||
'market_data',
|
'market_data',
|
||||||
'transactions' => function ($query) use ($symbol) {
|
'transactions' => function ($query) use ($symbol) {
|
||||||
$query->where('transactions.symbol', $symbol);
|
$query->where('transactions.symbol', $symbol);
|
||||||
}
|
},
|
||||||
])
|
])
|
||||||
->symbol($symbol)
|
->symbol($symbol)
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
// if ($holding->quantity <= 0) {
|
$formattedTransactions = $holding->getFormattedTransactions();
|
||||||
|
|
||||||
// return redirect(route('portfolio.show', ['portfolio' => $portfolio->id]));
|
return view('holding.show', compact(['portfolio', 'holding', 'formattedTransactions']));
|
||||||
// }
|
|
||||||
|
|
||||||
return view('holding.show', compact(['portfolio', 'holding']));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class InvitedOnboardingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the invited user needs a password?
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request, Portfolio $portfolio, User $user)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (! $request->hasValidSignature()) {
|
||||||
|
abort(401, 'Invalid signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// user doesn't have password
|
||||||
|
if (is_null($user->password)) {
|
||||||
|
|
||||||
|
// route to create password form
|
||||||
|
return view('auth.invited-onboarding', [
|
||||||
|
'portfolio' => $portfolio,
|
||||||
|
'user' => $user,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect user to portfolio
|
||||||
|
return redirect(route('portfolio.show', ['portfolio' => $portfolio->id]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\DailyChange;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class PortfolioController extends Controller
|
class PortfolioController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* Show the form for creating a new resource.
|
||||||
*/
|
*/
|
||||||
@@ -20,22 +22,26 @@ class PortfolioController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Portfolio $portfolio)
|
public function show(Request $request, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
|
Gate::authorize('readOnly', $portfolio);
|
||||||
|
|
||||||
$portfolio->load(['transactions', 'holdings']);
|
$portfolio->load(['transactions', 'holdings']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->tags(['metrics', 'portfolio', $portfolio->id])->remember(
|
$metrics = cache()->remember(
|
||||||
'portfolio-metrics-' . $portfolio->id,
|
'portfolio-metrics-'.$portfolio->id,
|
||||||
60,
|
60,
|
||||||
function () use ($portfolio) {
|
function () use ($portfolio) {
|
||||||
return Holding::query()
|
return Holding::query()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->withPortfolioMetrics()
|
->withPortfolioMetrics()
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return view('portfolio.show', compact(['portfolio', 'metrics']));
|
$formattedHoldings = $portfolio->getFormattedHoldings();
|
||||||
|
|
||||||
|
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
class TransactionController extends Controller
|
class TransactionController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -14,7 +16,7 @@ class SetLocale
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next)
|
public function handle(Request $request, Closure $next)
|
||||||
{
|
{
|
||||||
if (!session()->has('locale')) {
|
if (! session()->has('locale')) {
|
||||||
session()->put('locale', $request->getPreferredLanguage(
|
session()->put('locale', $request->getPreferredLanguage(
|
||||||
config('app.available_locales')
|
config('app.available_locales')
|
||||||
));
|
));
|
||||||
@@ -24,4 +26,4 @@ class SetLocale
|
|||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
|
||||||
|
|
||||||
|
class FormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
public function requestOrModelValue($key, $model): mixed
|
||||||
|
{
|
||||||
|
return $this->request->get($key) ?? $this->{$model}?->{$key};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
class HoldingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'reinvest_dividends' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
class PortfolioRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'wishlist' => ['sometimes', 'nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! is_null($this->portfolio)) {
|
||||||
|
$rules['title'][0] = 'sometimes';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Rules\QuantityValidationRule;
|
||||||
|
use App\Rules\SymbolValidationRule;
|
||||||
|
|
||||||
|
class TransactionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'portfolio' => Portfolio::find($this->requestOrModelValue('portfolio_id', 'transaction')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||||
|
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||||
|
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||||
|
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
|
||||||
|
'quantity' => [
|
||||||
|
'required',
|
||||||
|
'numeric',
|
||||||
|
'min:0',
|
||||||
|
new QuantityValidationRule(
|
||||||
|
$this->input('portfolio'),
|
||||||
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||||
|
$this->requestOrModelValue('date', 'transaction')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||||
|
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! is_null($this->transaction)) {
|
||||||
|
$rules['portfolio_id'][0] = 'sometimes';
|
||||||
|
$rules['symbol'][0] = 'sometimes';
|
||||||
|
$rules['transaction_type'][0] = 'sometimes';
|
||||||
|
$rules['date'][0] = 'sometimes';
|
||||||
|
$rules['quantity'][0] = 'sometimes';
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
|
||||||
|
&& $this->requestOrModelValue('sale_price', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['sale_price'][0] = 'required';
|
||||||
|
} elseif (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
|
||||||
|
&& $this->requestOrModelValue('cost_basis', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['cost_basis'][0] = 'required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class HoldingResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'portfolio_id' => $this->portfolio_id,
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'quantity' => $this->quantity,
|
||||||
|
'reinvest_dividends' => $this->reinvest_dividends,
|
||||||
|
'average_cost_basis' => $this->average_cost_basis,
|
||||||
|
'total_cost_basis' => $this->total_cost_basis,
|
||||||
|
'realized_gain_dollars' => $this->realized_gain_dollars,
|
||||||
|
'dividends_earned' => $this->dividends_earned,
|
||||||
|
'splits_synced_at' => $this->splits_synced_at,
|
||||||
|
'total_market_value' => $this->total_market_value,
|
||||||
|
'market_gain_dollars' => $this->market_gain_dollars,
|
||||||
|
'market_gain_percent' => $this->market_gain_percent,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class MarketDataResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'name' => $this->name,
|
||||||
|
'market_value' => $this->market_value,
|
||||||
|
'fifty_two_week_low' => $this->fifty_two_week_low,
|
||||||
|
'fifty_two_week_high' => $this->fifty_two_week_high,
|
||||||
|
'last_dividend_date' => $this->last_dividend_date,
|
||||||
|
'last_dividend_amount' => $this->last_dividend_amount,
|
||||||
|
'dividend_yield' => $this->dividend_yield,
|
||||||
|
'market_cap' => $this->market_cap,
|
||||||
|
'trailing_pe' => $this->trailing_pe,
|
||||||
|
'forward_pe' => $this->forward_pe,
|
||||||
|
'book_value' => $this->book_value,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PortfolioResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'wishlist' => $this->wishlist,
|
||||||
|
'owner' => UserResource::make($this->owner),
|
||||||
|
'transactions' => TransactionResource::collection($this->whenLoaded('transactions')),
|
||||||
|
'holdings' => HoldingResource::collection($this->whenLoaded('holdings')),
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TransactionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'portfolio_id' => $this->portfolio_id,
|
||||||
|
'transaction_type' => $this->transaction_type,
|
||||||
|
'quantity' => $this->quantity,
|
||||||
|
'cost_basis' => $this->cost_basis,
|
||||||
|
'sale_price' => $this->sale_price,
|
||||||
|
'split' => $this->split,
|
||||||
|
'reinvested_dividend' => $this->reinvested_dividend,
|
||||||
|
'date' => $this->date,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class UserResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'profile_photo_url' => $this->profile_photo_url,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,74 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports;
|
namespace App\Imports;
|
||||||
|
|
||||||
use App\Imports\Sheets\PortfoliosSheet;
|
use App\Console\Commands\RefreshDividendData;
|
||||||
|
use App\Console\Commands\RefreshMarketData;
|
||||||
|
use App\Console\Commands\SyncDailyChange;
|
||||||
|
use App\Console\Commands\SyncHoldingData;
|
||||||
use App\Imports\Sheets\DailyChangesSheet;
|
use App\Imports\Sheets\DailyChangesSheet;
|
||||||
|
use App\Imports\Sheets\PortfoliosSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
|
use App\Models\BackupImport as BackupImportModel;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Maatwebsite\Excel\Concerns\Importable;
|
use Maatwebsite\Excel\Concerns\Importable;
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
use Maatwebsite\Excel\Events\AfterImport;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeImport;
|
||||||
|
use Maatwebsite\Excel\Events\ImportFailed;
|
||||||
|
|
||||||
class BackupImport implements WithMultipleSheets, WithEvents
|
class BackupImport implements WithEvents, WithMultipleSheets
|
||||||
{
|
{
|
||||||
|
|
||||||
use Importable;
|
use Importable;
|
||||||
|
|
||||||
/**
|
public function __construct(
|
||||||
* @return array
|
public BackupImportModel $backupImportModel
|
||||||
*/
|
) {}
|
||||||
|
|
||||||
public function registerEvents(): array
|
public function registerEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
// BeforeSheet::class => DB::commit(),
|
BeforeImport::class => fn () => $this->backupImportModel->update([
|
||||||
// AfterSheet::class => Artisan::queue(RefreshHoldingData::class),
|
'status' => 'in_progress',
|
||||||
// AfterSheet::class => Artisan::call(RefreshHoldingData::class)
|
'message' => __('Import is in progress...'),
|
||||||
|
]),
|
||||||
|
AfterImport::class => function () {
|
||||||
|
|
||||||
|
$this->backupImportModel->update([
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Import completed successfully!',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
|
||||||
|
->chain([
|
||||||
|
fn () => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
|
||||||
|
fn () => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
|
||||||
|
fn () => User::find($this->backupImportModel->user_id)->portfolios->each(function ($portfolio) {
|
||||||
|
|
||||||
|
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
ImportFailed::class => fn (ImportFailed $event) => $this->backupImportModel->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Error: '.substr($event->getException()->getMessage(), 0, 220),
|
||||||
|
'has_errors' => true,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sheets(): array
|
public function sheets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Portfolios' => new PortfoliosSheet,
|
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||||
'Transactions' => new TransactionsSheet,
|
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||||
'Daily Changes' => new DailyChangesSheet,
|
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,97 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use Exception;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
|
use App\Models\BackupImport;
|
||||||
use App\Models\DailyChange;
|
use App\Models\DailyChange;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Imports\ValidatesPortfolioPermissions;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
use Maatwebsite\Excel\Concerns\WithChunkReading;
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading
|
class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
use ValidatesPortfolioPermissions;
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing daily changes...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function collection(Collection $dailyChanges)
|
public function collection(Collection $dailyChanges)
|
||||||
{
|
{
|
||||||
$this->validatePortfolioPermissions($dailyChanges);
|
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
||||||
|
|
||||||
foreach ($dailyChanges as $dailyChange) {
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
DailyChange::updateOrCreate([
|
// have to cast to native values
|
||||||
'date' => $dailyChange['date'],
|
$chunk = $chunk->map(function ($dailyChange) {
|
||||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
|
||||||
],[
|
return [
|
||||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
'total_market_value' => $dailyChange['total_market_value'],
|
||||||
'date' => $dailyChange['date'],
|
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
||||||
'total_market_value' => $dailyChange['total_market_value'],
|
'total_gain' => $dailyChange['total_gain'],
|
||||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
||||||
'total_gain' => $dailyChange['total_gain'],
|
'realized_gains' => $dailyChange['realized_gains'],
|
||||||
'total_dividends_earned' => $dailyChange['total_dividends'],
|
'annotation' => $dailyChange['annotation'],
|
||||||
'realized_gains' => $dailyChange['realized_gains'],
|
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||||
'annotation' => $dailyChange['annotation'],
|
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'),
|
||||||
]);
|
];
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DailyChange::upsert(
|
||||||
|
$chunk->toArray(),
|
||||||
|
['portfolio_id', 'date'],
|
||||||
|
[
|
||||||
|
'total_market_value',
|
||||||
|
'total_cost_basis',
|
||||||
|
'total_gain',
|
||||||
|
'total_dividends_earned',
|
||||||
|
'realized_gains',
|
||||||
|
'annotation',
|
||||||
|
'portfolio_id',
|
||||||
|
'date',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function batchSize(): int
|
||||||
|
{
|
||||||
|
return 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
||||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
||||||
'total_dividends' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
||||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chunkSize(): int
|
|
||||||
{
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,53 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
|
use App\Models\BackupImport;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows
|
class PortfoliosSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing portfolios...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function collection(Collection $portfolios)
|
public function collection(Collection $portfolios)
|
||||||
{
|
{
|
||||||
foreach ($portfolios as $portfolio) {
|
foreach ($portfolios as $index => $portfolio) {
|
||||||
|
|
||||||
Portfolio::unguard();
|
|
||||||
|
|
||||||
Portfolio::updateOrCreate([
|
Portfolio::unguard(); // ensures we can set an owner for the portfolio
|
||||||
'id' => $portfolio['portfolio_id']
|
|
||||||
|
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
|
||||||
|
'id' => $portfolio['portfolio_id'],
|
||||||
], [
|
], [
|
||||||
'id' => $portfolio['portfolio_id'] ?? null,
|
'id' => $portfolio['portfolio_id'] ?? null,
|
||||||
'title' => $portfolio['title'],
|
'title' => $portfolio['title'],
|
||||||
'wishlist' => $portfolio['wishlist'] ?? false,
|
'wishlist' => $portfolio['wishlist'] ?? false,
|
||||||
'notes' => $portfolio['notes'],
|
'notes' => $portfolio['notes'],
|
||||||
|
'owner_id' => $this->backupImport->user_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +55,7 @@ class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, S
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'portfolio_id' => ['sometimes', 'nullable'],
|
'portfolio_id' => ['sometimes', 'nullable', 'uuid'],
|
||||||
'title' => ['required', 'string'],
|
'title' => ['required', 'string'],
|
||||||
'wishlist' => ['sometimes', 'nullable', 'boolean'],
|
'wishlist' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'notes' => ['sometimes', 'nullable', 'string'],
|
'notes' => ['sometimes', 'nullable', 'string'],
|
||||||
|
|||||||
@@ -1,70 +1,121 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Imports\ValidatesPortfolioPermissions;
|
use Illuminate\Support\Str;
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
use Maatwebsite\Excel\Concerns\WithChunkReading;
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading
|
class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
use ValidatesPortfolioPermissions;
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing transactions...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function collection(Collection $transactions)
|
public function collection(Collection $transactions)
|
||||||
{
|
{
|
||||||
$this->validatePortfolioPermissions($transactions);
|
|
||||||
|
|
||||||
Transaction::withoutEvents(function () use ($transactions) {
|
|
||||||
|
|
||||||
foreach ($transactions->sortBy('date') as $transaction) {
|
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
||||||
|
|
||||||
Transaction::where('id', $transaction['transaction_id'])
|
$this->validatePortfolioAccess($chunk);
|
||||||
->firstOr(function () use ($transaction) {
|
|
||||||
|
|
||||||
$transaction = Transaction::make()->forceFill([
|
// have to cast to native values
|
||||||
'id' => $transaction['transaction_id'],
|
$chunk = $chunk->map(function ($transaction) {
|
||||||
'symbol' => $transaction['symbol'],
|
|
||||||
'portfolio_id' => $transaction['portfolio_id'],
|
|
||||||
'transaction_type' => $transaction['transaction_type'],
|
|
||||||
'quantity' => $transaction['quantity'],
|
|
||||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
|
||||||
'sale_price' => $transaction['sale_price'],
|
|
||||||
'split' => $transaction['split'] ?? null,
|
|
||||||
'date' => $transaction['date'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$transaction->save();
|
return [
|
||||||
|
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||||
|
'symbol' => strtoupper($transaction['symbol']),
|
||||||
|
'portfolio_id' => $transaction['portfolio_id'],
|
||||||
|
'transaction_type' => $transaction['transaction_type'],
|
||||||
|
'quantity' => $transaction['quantity'],
|
||||||
|
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||||
|
'sale_price' => $transaction['sale_price'],
|
||||||
|
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||||
|
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||||
|
'date' => Carbon::parse($transaction['date'])->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return $transaction;
|
Transaction::upsert(
|
||||||
})
|
$chunk->toArray(),
|
||||||
->syncToHolding();
|
['id'],
|
||||||
}
|
[
|
||||||
|
'id',
|
||||||
|
'symbol',
|
||||||
|
'portfolio_id',
|
||||||
|
'transaction_type',
|
||||||
|
'quantity',
|
||||||
|
'cost_basis',
|
||||||
|
'sale_price',
|
||||||
|
'split',
|
||||||
|
'reinvested_dividend',
|
||||||
|
'date',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// stub out related holdings
|
||||||
|
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||||
|
->each(function ($holding) {
|
||||||
|
|
||||||
|
Holding::firstOrCreate([
|
||||||
|
'symbol' => $holding['symbol'],
|
||||||
|
'portfolio_id' => $holding['portfolio_id'],
|
||||||
|
], [
|
||||||
|
'quantity' => 0,
|
||||||
|
'average_cost_basis' => 0,
|
||||||
|
'splits_synced_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function batchSize(): int
|
||||||
|
{
|
||||||
|
return 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'transaction_id' => ['sometimes', 'nullable'],
|
'transaction_id' => ['sometimes', 'nullable', 'uuid'],
|
||||||
'symbol' => ['required', 'string'],
|
'symbol' => ['required', 'string'],
|
||||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
|
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||||
|
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chunkSize(): int
|
|
||||||
{
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Imports;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
|
||||||
|
trait ValidatesPortfolioAccess
|
||||||
|
{
|
||||||
|
public function validatePortfolioAccess($collection)
|
||||||
|
{
|
||||||
|
|
||||||
|
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||||
|
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||||
|
->whereIn('id', $uniquePortfolios)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
||||||
|
) {
|
||||||
|
throw new \Exception(__('You do not have access to that portfolio.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Imports;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
trait ValidatesPortfolioPermissions {
|
|
||||||
|
|
||||||
public function validatePortfolioPermissions($collection)
|
|
||||||
{
|
|
||||||
$portfolios = auth()->user()->portfolios->pluck('id');
|
|
||||||
|
|
||||||
$collection->pluck('portfolio_id')->unique()->each(function($portfolio) use ($portfolios) {
|
|
||||||
|
|
||||||
if (!$portfolios->contains($portfolio)) {
|
|
||||||
|
|
||||||
throw new Exception('You do not have permission to access that portfolio.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -9,30 +15,28 @@ use Tschucki\Alphavantage\Facades\Alphavantage;
|
|||||||
|
|
||||||
class AlphaVantageMarketData implements MarketDataInterface
|
class AlphaVantageMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
public function exists(String $symbol): Bool
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
return (bool) $this->quote($symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Collection
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
||||||
$quote = Arr::get($quote, 'Global Quote', []);
|
$quote = Arr::get($quote, 'Global Quote', []);
|
||||||
|
|
||||||
$fundamental = cache()->tags(['quote', 'alpha-vantage', $symbol])->remember(
|
$fundamental = cache()->remember(
|
||||||
'symbol-'.$symbol,
|
'av-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol) {
|
||||||
return Alphavantage::fundamentals()->overview($symbol);
|
return Alphavantage::fundamentals()->overview($symbol);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (empty($fundamental)) return collect();
|
return new Quote([
|
||||||
|
|
||||||
return collect([
|
|
||||||
'name' => Arr::get($fundamental, 'Name'),
|
'name' => Arr::get($fundamental, 'Name'),
|
||||||
'symbol' => Arr::get($fundamental, 'Symbol'),
|
'symbol' => $symbol,
|
||||||
'market_value' => Arr::get($quote, '05. price'),
|
'market_value' => Arr::get($quote, '05. price'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
||||||
@@ -45,73 +49,71 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
: null,
|
: null,
|
||||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||||
? Arr::get($fundamental, 'DividendYield')
|
? Arr::get($fundamental, 'DividendYield')
|
||||||
: null
|
: null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$dividends = Alphavantage::fundamentals()->dividends($symbol);
|
$dividends = Alphavantage::fundamentals()->dividends($symbol);
|
||||||
$dividends = Arr::get($dividends, 'data', []);
|
$dividends = Arr::get($dividends, 'data', []);
|
||||||
|
|
||||||
return collect($dividends)
|
return collect($dividends)
|
||||||
->filter(function($dividend) use ($startDate, $endDate) {
|
->filter(function ($dividend) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->map(function($dividend) use ($symbol) {
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return [
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))
|
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
|
||||||
->format('Y-m-d H:i:s'),
|
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
]);
|
||||||
];
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$splits = Alphavantage::fundamentals()->splits($symbol);
|
$splits = Alphavantage::fundamentals()->splits($symbol);
|
||||||
$splits = Arr::get($splits, 'data', []);
|
$splits = Arr::get($splits, 'data', []);
|
||||||
|
|
||||||
return collect($splits)
|
return collect($splits)
|
||||||
->filter(function($split) use ($startDate, $endDate) {
|
->filter(function ($split) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->map(function($split) use ($symbol) {
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
return [
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($split, 'effective_date'))
|
'date' => Carbon::parse(Arr::get($split, 'effective_date')),
|
||||||
->format('Y-m-d H:i:s'),
|
'split_amount' => Arr::get($split, 'split_factor'),
|
||||||
'split_amount' => Arr::get($split, 'split_factor'),
|
]);
|
||||||
];
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$history = Alphavantage::timeSeries()->daily($symbol, 'full');
|
$history = Alphavantage::timeSeries()->daily($symbol, 'full');
|
||||||
|
|
||||||
$history = Arr::get($history, 'Time Series (Daily)', []);
|
$history = Arr::get($history, 'Time Series (Daily)', []);
|
||||||
|
|
||||||
return collect($history)
|
return collect($history)
|
||||||
->filter(function ($history, $date) use ($startDate, $endDate) {
|
->filter(function ($history, $date) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse($date)->between($startDate, $endDate);
|
return Carbon::parse($date)->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->mapWithKeys(function($history, $date) use ($symbol) {
|
->mapWithKeys(function ($history, $date) use ($symbol) {
|
||||||
|
|
||||||
$date = Carbon::parse($date)->format('Y-m-d');
|
$date = Carbon::parse($date)->format('Y-m-d');
|
||||||
|
|
||||||
return [ $date => [
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => (float) Arr::get($history, '4. close')
|
'close' => Arr::get($history, '4. close'),
|
||||||
]];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class FakeMarketData implements MarketDataInterface
|
class FakeMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
public function exists(String $symbol): Bool
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Collection
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect([
|
return new Quote([
|
||||||
'name' => 'ACME Company Ltd',
|
'name' => 'ACME Company Ltd',
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'market_value' => 230.19,
|
'market_value' => 230.19,
|
||||||
@@ -27,59 +33,59 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
'market_cap' => 9800700600,
|
'market_cap' => 9800700600,
|
||||||
'book_value' => 4.7,
|
'book_value' => 4.7,
|
||||||
'last_dividend_date' => now()->subDays(45),
|
'last_dividend_date' => now()->subDays(45),
|
||||||
'dividend_yield' => .033
|
'dividend_yield' => 0.033,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect([
|
return collect([
|
||||||
[
|
new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(3)->format('Y-m-d H:i:s'),
|
'date' => now()->subMonths(3),
|
||||||
'dividend_amount' => 2.11,
|
'dividend_amount' => 2.11,
|
||||||
],
|
]),
|
||||||
[
|
new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(6)->format('Y-m-d H:i:s'),
|
'date' => now()->subMonths(6),
|
||||||
'dividend_amount' => 1.89,
|
'dividend_amount' => 1.89,
|
||||||
],
|
]),
|
||||||
[
|
new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(9)->format('Y-m-d H:i:s'),
|
'date' => now()->subMonths(9),
|
||||||
'dividend_amount' => 0.95,
|
'dividend_amount' => 0.95,
|
||||||
],
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect([
|
return collect([
|
||||||
[
|
new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(36)->format('Y-m-d H:i:s'),
|
'date' => now()->subMonths(36),
|
||||||
'split_amount' => 10,
|
'split_amount' => 10,
|
||||||
],
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
||||||
|
|
||||||
for ($i = 0; $i < $numDays; $i++) {
|
for ($i = 0; $i < $numDays; $i++) {
|
||||||
|
|
||||||
$date = now()->subDays($i)->format('Y-m-d');
|
$date = now()->subDays($i)->format('Y-m-d');
|
||||||
|
|
||||||
$series[$date] = [
|
$series[$date] = new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => (float) rand(150, 400),
|
'close' => rand(150, 400),
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($series);
|
return collect($series);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class FallbackInterface
|
class FallbackInterface
|
||||||
{
|
{
|
||||||
|
|
||||||
protected string $latest_error;
|
protected string $latest_error;
|
||||||
|
|
||||||
public function __call($method, $arguments)
|
public function __call($method, $arguments)
|
||||||
{
|
{
|
||||||
|
|
||||||
$providers = explode(',', config('investbrain.provider', 'yahoo'));
|
$providers = explode(',', config('investbrain.provider', 'yahoo'));
|
||||||
|
|
||||||
foreach ($providers as $provider) {
|
foreach ($providers as $provider) {
|
||||||
|
|
||||||
$provider = trim($provider);
|
$provider = trim($provider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Log::warning("Calling method {$method} ({$provider})");
|
||||||
|
|
||||||
if (!in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||||
|
|
||||||
throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
|
throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
|
||||||
}
|
}
|
||||||
@@ -30,13 +32,20 @@ class FallbackInterface
|
|||||||
return app()->make($provider_class_name)->$method(...$arguments);
|
return app()->make($provider_class_name)->$method(...$arguments);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
$this->latest_error = $e->getMessage();
|
$this->latest_error = $e->getMessage();
|
||||||
|
|
||||||
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// don't need to throw error if calling exists
|
||||||
|
if ($method == 'exists') {
|
||||||
|
|
||||||
|
// symbol prob just doesn't exist
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
throw new \Exception("Could not get market data: {$this->latest_error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -12,38 +18,35 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|
||||||
$this->client = new \Finnhub\Api\DefaultApi(
|
$this->client = new \Finnhub\Api\DefaultApi(
|
||||||
new \GuzzleHttp\Client(),
|
new \GuzzleHttp\Client,
|
||||||
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
|
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
public function exists(String $symbol): Bool
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
return (bool) $this->quote($symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote($symbol): Collection
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
$quote = $this->client->quote($symbol);
|
$quote = $this->client->quote($symbol);
|
||||||
|
|
||||||
$fundamental = cache()->tags(['quote', 'finnhub', $symbol])->remember(
|
$fundamental = cache()->remember(
|
||||||
'symbol-'.$symbol,
|
'fh-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol) {
|
||||||
return $this->client->companyBasicFinancials($symbol, "all");
|
return $this->client->companyBasicFinancials($symbol, 'all');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (empty($fundamental)) return collect();
|
return new Quote([
|
||||||
|
|
||||||
return collect([
|
|
||||||
'name' => Arr::get($fundamental, 'metric.name'),
|
'name' => Arr::get($fundamental, 'metric.name'),
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'market_value' => Arr::get($quote, 'c'),
|
'market_value' => Arr::get($quote, 'c'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
||||||
@@ -52,55 +55,54 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
||||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
||||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends($symbol, $startDate, $endDate): Collection
|
public function dividends($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||||
|
|
||||||
return collect($dividends)->map(function($dividend) use ($symbol) {
|
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return [
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($dividend, 'date'))
|
'date' => Carbon::parse(Arr::get($dividend, 'date')),
|
||||||
->format('Y-m-d H:i:s'),
|
|
||||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits($symbol, $startDate, $endDate): Collection
|
public function splits($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
||||||
|
|
||||||
return collect($splits)->map(function($split) use ($symbol) {
|
return collect($splits)->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
return [
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($split, 'date'))
|
'date' => Carbon::parse(Arr::get($split, 'date')),
|
||||||
->format('Y-m-d H:i:s'),
|
|
||||||
'split_amount' => Arr::get($split, 'toFactor') / Arr::get($split, 'fromFactor'),
|
'split_amount' => Arr::get($split, 'toFactor') / Arr::get($split, 'fromFactor'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history($symbol, $startDate, $endDate): Collection
|
public function history($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$history = $this->client->stockCandles($symbol, "D", $startDate->timestamp, $endDate->timestamp);
|
$history = $this->client->stockCandles($symbol, 'D', $startDate->timestamp, $endDate->timestamp);
|
||||||
|
|
||||||
$timestamps = Arr::get($history, 't', []);
|
$timestamps = Arr::get($history, 't', []);
|
||||||
$closes = Arr::get($history, 'c', []);
|
$closes = Arr::get($history, 'c', []);
|
||||||
|
|
||||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
||||||
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
||||||
return [ $date => [
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => (float) $closes[$index],
|
'close' => $closes[$index],
|
||||||
]];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
interface MarketDataInterface
|
interface MarketDataInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Does this symbol actually exist?
|
* Does this symbol actually exist?
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
*
|
|
||||||
* @return Bool
|
|
||||||
*/
|
*/
|
||||||
public function exists(String $symbol): Bool;
|
public function exists(string $symbol): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get quote data
|
* Get quote data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function quote(String $symbol): Collection;
|
public function quote(string $symbol): Quote;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dividend data
|
* Get dividend data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function dividends(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function dividends(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get split data
|
* Get split data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function splits(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function splits(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get historical close data
|
* Get historical close data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function history(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function history(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class Dividend extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setSymbol(string $symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDividendAmount($dividendAmount): self
|
||||||
|
{
|
||||||
|
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDividendAmount(): float
|
||||||
|
{
|
||||||
|
return $this->items['dividend_amount'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDate(string|DateTime $date): self
|
||||||
|
{
|
||||||
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['date'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class MarketDataType extends Collection
|
||||||
|
{
|
||||||
|
public function __construct($items = [])
|
||||||
|
{
|
||||||
|
|
||||||
|
foreach ($this->getArrayableItems($items) as $key => $value) {
|
||||||
|
|
||||||
|
$this->{$key} = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray()
|
||||||
|
{
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __set($key, $value)
|
||||||
|
{
|
||||||
|
$this->{'set'.Str::studly($key)}($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __get($key)
|
||||||
|
{
|
||||||
|
return $this->items[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class Ohlc extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setSymbol(string $symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpen($open): self
|
||||||
|
{
|
||||||
|
$this->items['open'] = (float) $open;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpen(): float
|
||||||
|
{
|
||||||
|
return $this->items['open'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHigh($high): self
|
||||||
|
{
|
||||||
|
$this->items['high'] = (float) $high;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHigh(): float
|
||||||
|
{
|
||||||
|
return $this->items['high'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLow($low): self
|
||||||
|
{
|
||||||
|
$this->items['low'] = (float) $low;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLow(): float
|
||||||
|
{
|
||||||
|
return $this->items['low'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClose($close): self
|
||||||
|
{
|
||||||
|
$this->items['close'] = (float) $close;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClose(): float
|
||||||
|
{
|
||||||
|
return $this->items['close'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDate(string|DateTime $date): self
|
||||||
|
{
|
||||||
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['date'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class Quote extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setName($name): self
|
||||||
|
{
|
||||||
|
if (! empty($name)) {
|
||||||
|
$this->items['name'] = (string) $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->items['name'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSymbol(string $symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = (string) $symbol;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMarketValue($marketValue): self
|
||||||
|
{
|
||||||
|
$this->items['market_value'] = (float) $marketValue;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMarketValue(): float
|
||||||
|
{
|
||||||
|
return $this->items['market_value'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFiftyTwoWeekHigh($high): self
|
||||||
|
{
|
||||||
|
$this->items['fifty_two_week_high'] = (float) $high;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFiftyTwoWeekHigh(): float
|
||||||
|
{
|
||||||
|
return $this->items['fifty_two_week_high'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFiftyTwoWeekLow($low): self
|
||||||
|
{
|
||||||
|
$this->items['fifty_two_week_low'] = (float) $low;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFiftyTwoWeekLow(): float
|
||||||
|
{
|
||||||
|
return $this->items['fifty_two_week_low'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setForwardPE($pe): self
|
||||||
|
{
|
||||||
|
$this->items['forward_pe'] = (float) $pe;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForwardPE(): float
|
||||||
|
{
|
||||||
|
return $this->items['forward_pe'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrailingPE($pe): self
|
||||||
|
{
|
||||||
|
$this->items['trailing_pe'] = (float) $pe;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrailingPE(): float
|
||||||
|
{
|
||||||
|
return $this->items['trailing_pe'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMarketCap($cap): self
|
||||||
|
{
|
||||||
|
$this->items['market_cap'] = (int) $cap;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMarketCap(): int
|
||||||
|
{
|
||||||
|
return $this->items['market_cap'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBookValue($value): self
|
||||||
|
{
|
||||||
|
$this->items['book_value'] = (float) $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBookValue(): float
|
||||||
|
{
|
||||||
|
return $this->items['book_value'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastDividendDate(mixed $date): self
|
||||||
|
{
|
||||||
|
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastDividendDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['last_dividend_date'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDividendYield($yield): self
|
||||||
|
{
|
||||||
|
$this->items['dividend_yield'] = (float) $yield;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDividendYield(): float
|
||||||
|
{
|
||||||
|
return $this->items['dividend_yield'] ?? 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class Split extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setSymbol(string $symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSplitAmount($splitAmount): self
|
||||||
|
{
|
||||||
|
$this->items['split_amount'] = (float) $splitAmount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSplitAmount(): float
|
||||||
|
{
|
||||||
|
return $this->items['split_amount'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDate(string|DateTime $date): self
|
||||||
|
{
|
||||||
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['date'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Scheb\YahooFinanceApi\ApiClient;
|
use Scheb\YahooFinanceApi\ApiClient;
|
||||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||||
@@ -10,82 +16,81 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
public ApiClient $client;
|
public ApiClient $client;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct()
|
||||||
|
{
|
||||||
|
|
||||||
// create yahoo finance client factory
|
// create yahoo finance client factory
|
||||||
$this->client = YahooFinance::createApiClient();
|
$this->client = YahooFinance::createApiClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exists(String $symbol): Bool
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
return (bool) $this->quote($symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Collection
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
$quote = $this->client->getQuote($symbol);
|
$quote = $this->client->getQuote($symbol);
|
||||||
|
|
||||||
if (empty($quote)) return collect();
|
return new Quote([
|
||||||
|
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
||||||
return collect([
|
'symbol' => $symbol,
|
||||||
'name' => $quote->getLongName() ?? $quote->getShortName(),
|
'market_value' => $quote?->getRegularMarketPrice(),
|
||||||
'symbol' => $quote->getSymbol(),
|
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||||
'market_value' => $quote->getRegularMarketPrice(),
|
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||||
'fifty_two_week_high' => $quote->getFiftyTwoWeekHigh(),
|
'forward_pe' => $quote?->getForwardPE(),
|
||||||
'fifty_two_week_low' => $quote->getFiftyTwoWeekLow(),
|
'trailing_pe' => $quote?->getTrailingPE(),
|
||||||
'forward_pe' => $quote->getForwardPE(),
|
'market_cap' => $quote?->getMarketCap(),
|
||||||
'trailing_pe' => $quote->getTrailingPE(),
|
'book_value' => $quote?->getBookValue(),
|
||||||
'market_cap' => $quote->getMarketCap(),
|
'last_dividend_date' => $quote?->getDividendDate(),
|
||||||
'book_value' => $quote->getBookValue(),
|
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
||||||
'last_dividend_date' => $quote->getDividendDate(),
|
|
||||||
'dividend_yield' => $quote->getTrailingAnnualDividendYield() * 100
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
|
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
|
||||||
->map(function($dividend) use ($symbol) {
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return [
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $dividend->getDate()->format('Y-m-d H:i:s'),
|
'date' => $dividend->getDate(),
|
||||||
'dividend_amount' => $dividend->getDividends(),
|
'dividend_amount' => $dividend->getDividends(),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
|
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
|
||||||
->map(function($split) use ($symbol) {
|
->map(function ($split) use ($symbol) {
|
||||||
$split_amount = explode(':', $split->getStockSplits());
|
$split_amount = explode(':', $split->getStockSplits());
|
||||||
|
|
||||||
return [
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $split->getDate()->format('Y-m-d H:i:s'),
|
'date' => $split->getDate(),
|
||||||
'split_amount' => $split_amount[0] / $split_amount[1],
|
'split_amount' => $split_amount[0] / $split_amount[1],
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||||
->mapWithKeys(function($history) use ($symbol) {
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
$date = $history->getDate()->format('Y-m-d');
|
$date = $history->getDate()->format('Y-m-d');
|
||||||
|
|
||||||
return [ $date => [
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => (float) $history->getClose(),
|
'close' => $history->getClose(),
|
||||||
]];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Imports\BackupImport as BackupImportExcel;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ImportFailedNotification;
|
||||||
|
use App\Notifications\ImportSucceededNotification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class BackupImportJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds the job can run before timing out.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $timeout = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate if the job should be marked as failed on timeout.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $failOnTimeout = true;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {
|
||||||
|
$this->user = User::find($this->backupImport->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
|
||||||
|
|
||||||
|
$this->user->notify(new ImportSucceededNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(?Throwable $e): void
|
||||||
|
{
|
||||||
|
$this->backupImport->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Error: '.substr($e->getMessage(), 0, 220),
|
||||||
|
'has_errors' => true,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->user->notify(new ImportFailedNotification($e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AiChat extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'role',
|
||||||
|
'content',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($chat) {
|
||||||
|
|
||||||
|
$chat->user_id = auth()->user()->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Jobs\BackupImportJob;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BackupImport extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $table = 'backup_import_jobs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'path',
|
||||||
|
'status', // pending, in_progress, success, failed
|
||||||
|
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
|
||||||
|
'has_errors',
|
||||||
|
'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($import) {
|
||||||
|
|
||||||
|
$import->status = 'pending';
|
||||||
|
$import->message = __('Import starting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
static::created(function ($import) {
|
||||||
|
|
||||||
|
BackupImportJob::dispatch($import);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected $appends = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'has_errors' => 'boolean',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ConnectedAccount extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use HasTimestamps;
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'provider',
|
||||||
|
'provider_id',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'refresh_token',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $with = [
|
||||||
|
'user',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user of the connected account.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasCompositePrimaryKey;
|
use App\Traits\HasCompositePrimaryKey;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class DailyChange extends Model
|
class DailyChange extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, HasCompositePrimaryKey;
|
use HasCompositePrimaryKey, HasFactory;
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
@@ -32,21 +34,28 @@ class DailyChange extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyDailyChanges()
|
public function scopeMyDailyChanges()
|
||||||
{
|
{
|
||||||
return $this->whereHas('portfolio', function ($query) {
|
return $this->whereHas('portfolio', function ($query) {
|
||||||
$query->whereHas('users', function ($query) {
|
$query->whereHas('users', function ($query) {
|
||||||
$query->where('id', auth()->id());
|
return $query->where('id', auth()->id());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeWithoutWishlists($query)
|
||||||
|
{
|
||||||
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
|
$query->where('portfolios.wishlist', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function portfolio()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
|
|||||||
+88
-49
@@ -1,15 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Holding;
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Dividend extends Model
|
class Dividend extends Model
|
||||||
{
|
{
|
||||||
@@ -26,18 +27,21 @@ class Dividend extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'last_date' => 'datetime',
|
'last_dividend_update' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function marketData() {
|
public function marketData()
|
||||||
|
{
|
||||||
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
|
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function holdings() {
|
public function holdings()
|
||||||
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions() {
|
public function transactions()
|
||||||
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,26 +52,29 @@ class Dividend extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Grab new dividend data
|
* Grab new dividend data
|
||||||
*
|
|
||||||
* @param string $symbol
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public static function refreshDividendData(string $symbol)
|
public static function refreshDividendData(string $symbol): void
|
||||||
{
|
{
|
||||||
$dividends_meta = self::where(['symbol' => $symbol])
|
$dividends_meta = self::where(['symbol' => $symbol])
|
||||||
->selectRaw('COUNT(symbol) as total_dividends')
|
->selectRaw('COUNT(symbol) as total_dividends')
|
||||||
->selectRaw('MAX(date) as last_date')
|
->selectRaw('MAX(created_at) as last_dividend_update')
|
||||||
->get()
|
->get()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
// assume we need to populate ALL dividend data
|
// assume we need to populate ALL dividend data
|
||||||
$start_date = new \DateTime('@0');
|
$start_date = new Carbon('@0');
|
||||||
$end_date = now();
|
$end_date = now();
|
||||||
|
|
||||||
// nope, refresh forward looking only
|
// nope, refresh forward looking only
|
||||||
if ( $dividends_meta->total_dividends ) {
|
if ($dividends_meta->total_dividends) {
|
||||||
|
|
||||||
$start_date = $dividends_meta->last_date->addHours(48);
|
$start_date = $dividends_meta->last_dividend_update->addHours(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip refresh if there's already recent data
|
||||||
|
if ($start_date->greaterThan($end_date)) {
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get some data
|
// get some data
|
||||||
@@ -78,7 +85,7 @@ class Dividend extends Model
|
|||||||
// ah, we found some dividends...
|
// ah, we found some dividends...
|
||||||
if ($dividend_data->isNotEmpty()) {
|
if ($dividend_data->isNotEmpty()) {
|
||||||
// create mass insert
|
// create mass insert
|
||||||
foreach ($dividend_data as $index => $dividend){
|
foreach ($dividend_data as $index => $dividend) {
|
||||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,48 +93,80 @@ class Dividend extends Model
|
|||||||
(new self)->insert($dividend_data->toArray());
|
(new self)->insert($dividend_data->toArray());
|
||||||
|
|
||||||
// sync to holdings
|
// sync to holdings
|
||||||
self::syncHoldings($dividend_data);
|
self::syncHoldings($symbol);
|
||||||
|
|
||||||
|
// get market data
|
||||||
|
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
||||||
|
|
||||||
|
// re-invest dividends
|
||||||
|
self::reinvestDividends($dividend_data, $market_data);
|
||||||
|
|
||||||
// sync last dividend amount to market data table
|
// sync last dividend amount to market data table
|
||||||
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
|
||||||
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
|
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
|
||||||
$market_data->save();
|
$market_data->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $dividend_data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function syncHoldings($dividend_data): void
|
public static function syncHoldings(string $symbol): void
|
||||||
{
|
{
|
||||||
$symbol = $dividend_data->last()['symbol'];
|
|
||||||
|
|
||||||
// group by holdings
|
// group by holdings
|
||||||
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
|
$subQuery = self::select([
|
||||||
->selectRaw('
|
'holdings.portfolio_id',
|
||||||
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
|
'dividends.date',
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
'dividends.symbol',
|
||||||
THEN transactions.quantity ELSE 0 END, 0)
|
'dividends.dividend_amount',
|
||||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
])->selectRaw("
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
|
||||||
THEN transactions.quantity ELSE 0 END, 0))
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
* dividends.dividend_amount
|
THEN transactions.quantity ELSE 0 END), 0)
|
||||||
AS total_received
|
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
|
||||||
')
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
THEN transactions.quantity ELSE 0 END), 0))
|
||||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
* dividends.dividend_amount
|
||||||
->where('dividends.symbol', $dividend_data->last()['symbol'])
|
AS total_received
|
||||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||||
->havingRaw('total_received > 0')
|
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||||
->get();
|
->where('dividends.symbol', $symbol)
|
||||||
|
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount');
|
||||||
|
|
||||||
// iterate through holdings and update
|
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||||
|
->mergeBindings($subQuery->getQuery())
|
||||||
|
->where('total_received', '>', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// iterate through holdings and update
|
||||||
Holding::where(['symbol' => $symbol])
|
Holding::where(['symbol' => $symbol])
|
||||||
->get()
|
->get()
|
||||||
->each(function ($holding) use ($dividends) {
|
->each(function ($holding) use ($dividends) {
|
||||||
$holding->update([
|
$holding->update([
|
||||||
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
||||||
->sum('total_received')
|
->sum('total_received'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
|
||||||
|
{
|
||||||
|
// re-invest dividends
|
||||||
|
Holding::where([
|
||||||
|
'symbol' => $market_data->symbol,
|
||||||
|
'reinvest_dividends' => true,
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->each(function ($holding) use ($dividend_data, $market_data) {
|
||||||
|
|
||||||
|
foreach ($dividend_data as $dividend) {
|
||||||
|
|
||||||
|
Transaction::create([
|
||||||
|
'date' => $dividend['date'],
|
||||||
|
'portfolio_id' => $holding->portfolio_id,
|
||||||
|
'symbol' => $holding->symbol,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
'reinvested_dividend' => true,
|
||||||
|
'cost_basis' => 0,
|
||||||
|
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
|
||||||
]);
|
]);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-137
@@ -1,16 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Split;
|
|
||||||
use App\Models\Dividend;
|
|
||||||
use App\Models\Portfolio;
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Holding extends Model
|
class Holding extends Model
|
||||||
{
|
{
|
||||||
@@ -26,16 +24,13 @@ class Holding extends Model
|
|||||||
'realized_gain_dollars',
|
'realized_gain_dollars',
|
||||||
'dividends_earned',
|
'dividends_earned',
|
||||||
'splits_synced_at',
|
'splits_synced_at',
|
||||||
|
'reinvest_dividends',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'splits_synced_at' => 'datetime',
|
'splits_synced_at' => 'datetime',
|
||||||
'first_transaction_date' => 'datetime'
|
'first_transaction_date' => 'datetime',
|
||||||
];
|
'reinvest_dividends' => 'boolean',
|
||||||
|
|
||||||
protected $attributes = [
|
|
||||||
'realized_gain_dollars' => 0,
|
|
||||||
'dividends_earned' => 0,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +38,7 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function market_data()
|
public function market_data()
|
||||||
{
|
{
|
||||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -53,7 +48,7 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function transactions()
|
public function transactions()
|
||||||
{
|
{
|
||||||
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
|
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
|
||||||
}
|
}
|
||||||
@@ -63,35 +58,67 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function dividends()
|
public function dividends()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
||||||
->select(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
|
||||||
->selectRaw("SUM(
|
->selectRaw("SUM(
|
||||||
CASE WHEN transaction_type = 'BUY'
|
CASE WHEN transaction_type = 'BUY'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND dividends.date >= transactions.date
|
AND date(dividends.date) >= date(transactions.date)
|
||||||
THEN transactions.quantity
|
THEN transactions.quantity
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
) AS purchased")
|
) AS purchased")
|
||||||
->selectRaw("SUM(
|
->selectRaw("SUM(
|
||||||
CASE WHEN transaction_type = 'SELL'
|
CASE WHEN transaction_type = 'SELL'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND dividends.date >= transactions.date
|
AND date(dividends.date) >= date(transactions.date)
|
||||||
THEN transactions.quantity
|
THEN transactions.quantity
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
) AS sold")
|
) AS sold")
|
||||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
->selectRaw("SUM(
|
||||||
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
(CASE WHEN transaction_type = 'BUY'
|
||||||
->orderBy('dividends.date', 'DESC')
|
AND transactions.symbol = dividends.symbol
|
||||||
->where('dividends.date', '>=', function ($query) {
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
$query->selectRaw('min(transactions.date)')
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
->from('transactions')
|
THEN transactions.quantity ELSE 0 END
|
||||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
- CASE WHEN transaction_type = 'SELL'
|
||||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
AND transactions.symbol = dividends.symbol
|
||||||
});
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
|
THEN transactions.quantity ELSE 0 END)
|
||||||
|
* dividends.dividend_amount
|
||||||
|
) AS total_received")
|
||||||
|
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||||
|
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
|
||||||
|
->orderBy('dividends.date', 'DESC')
|
||||||
|
->where('dividends.date', '>=', function ($query) {
|
||||||
|
$query->selectRaw('min(transactions.date)')
|
||||||
|
->from('transactions')
|
||||||
|
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||||
|
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||||
|
})
|
||||||
|
->havingRaw("SUM(
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'BUY'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
-
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'SELL'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
) * dividends.dividend_amount > 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +126,7 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function portfolio()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
}
|
}
|
||||||
@@ -109,27 +136,37 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function splits()
|
public function splits()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Split::class, 'symbol', 'symbol')
|
return $this->hasMany(Split::class, 'symbol', 'symbol')
|
||||||
->orderBy('date', 'DESC');
|
->orderBy('date', 'DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Related chats for holding
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function chats()
|
||||||
|
{
|
||||||
|
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeWithMarketData($query)
|
public function scopeWithMarketData($query)
|
||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithPerformance($query)
|
public function scopeWithPerformance($query)
|
||||||
{
|
{
|
||||||
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
||||||
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
||||||
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis), 0) AS market_gain_percent');
|
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
@@ -142,135 +179,191 @@ class Holding extends Model
|
|||||||
return $query->where('holdings.symbol', $symbol);
|
return $query->where('holdings.symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists($query) {
|
public function scopeWithoutWishlists($query)
|
||||||
return $query->join('portfolios', 'portfolios.id', 'holdings.portfolio_id')
|
|
||||||
->where('portfolios.wishlist', 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeMyHoldings($query)
|
|
||||||
{
|
{
|
||||||
return $query->whereHas('portfolio', function($query) {
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
$query->whereRelation('users', 'id', auth()->user()->id);
|
$query->where('portfolios.wishlist', 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithPortfolioMetrics($query)
|
public function scopeMyHoldings($query, $userId = null)
|
||||||
|
{
|
||||||
|
return $query->whereHas('portfolio', function ($query) use ($userId) {
|
||||||
|
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithPortfolioMetrics($query)
|
||||||
{
|
{
|
||||||
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
|
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
|
||||||
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
|
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
|
||||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
|
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
|
||||||
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
||||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
||||||
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
||||||
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncTransactionsAndDividends()
|
public function syncTransactionsAndDividends()
|
||||||
{
|
{
|
||||||
// pull existing transaction data
|
// pull existing transaction data
|
||||||
$query = Transaction::where([
|
$query = Transaction::where([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`')
|
])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `cost_basis`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN ((sale_price - cost_basis) * quantity) ELSE 0 END) AS `realized_gains`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (quantity * sale_price) ELSE 0 END) AS total_sale_price")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 5);
|
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
|
||||||
|
|
||||||
$average_cost_basis = (
|
$average_cost_basis = (
|
||||||
$query->qty_purchases > 0
|
$query->qty_purchases > 0
|
||||||
&& $total_quantity > 0
|
&& $total_quantity > 0
|
||||||
)
|
) ? $query->total_cost_basis / $query->qty_purchases
|
||||||
? $query->cost_basis / $query->qty_purchases
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
// pull dividend data joined with holdings/transactions
|
|
||||||
$dividends = Dividend::select('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount')
|
|
||||||
->selectRaw('
|
|
||||||
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
|
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
|
||||||
THEN transactions.quantity ELSE 0 END, 0)
|
|
||||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
|
||||||
THEN transactions.quantity ELSE 0 END, 0))
|
|
||||||
* dividends.dividend_amount
|
|
||||||
AS total_received
|
|
||||||
')
|
|
||||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
|
||||||
->join('holdings', 'transactions.portfolio_id', 'holdings.portfolio_id')
|
|
||||||
->where('dividends.symbol', $this->symbol)
|
|
||||||
->where('transactions.portfolio_id', $this->portfolio_id)
|
|
||||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// update holding
|
// update holding
|
||||||
$this->fill([
|
$this->fill([
|
||||||
'quantity' => $total_quantity,
|
'quantity' => $total_quantity,
|
||||||
'average_cost_basis' => $average_cost_basis,
|
'average_cost_basis' => $average_cost_basis,
|
||||||
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
||||||
'realized_gain_dollars' => $query->realized_gains,
|
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
|
||||||
'dividends_earned' => $dividends->sum('total_received')
|
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
|
||||||
|
: 0,
|
||||||
|
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
|
||||||
|
{
|
||||||
|
if ($date == null) {
|
||||||
|
$date = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $this->transactions->where('date', '<=', $date);
|
||||||
|
|
||||||
|
$purchases = $transactions->where('transaction_type', 'BUY')->sum('quantity');
|
||||||
|
|
||||||
|
$sales = $transactions->where('transaction_type', 'SELL')->sum('quantity');
|
||||||
|
|
||||||
|
return $purchases - $sales;
|
||||||
|
}
|
||||||
|
|
||||||
public function dailyPerformance(
|
public function dailyPerformance(
|
||||||
\Illuminate\Support\Carbon $start_date = null,
|
?\Illuminate\Support\Carbon $start_date = null,
|
||||||
\Illuminate\Support\Carbon $end_date = null,
|
?\Illuminate\Support\Carbon $end_date = null,
|
||||||
) {
|
) {
|
||||||
if ($start_date == null) $start_date = now();
|
if ($start_date == null) {
|
||||||
if ($end_date == null) $end_date = now();
|
$start_date = now();
|
||||||
|
}
|
||||||
|
if ($end_date == null) {
|
||||||
|
$end_date = now();
|
||||||
|
}
|
||||||
|
|
||||||
$date_interval = "DATE_ADD(date, INTERVAL 1 DAY)";
|
// MySQL default interval
|
||||||
|
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
||||||
|
$castNumberType = 'decimal';
|
||||||
|
|
||||||
|
// Use SQLite interval grammar
|
||||||
if (config('database.default') === 'sqlite') {
|
if (config('database.default') === 'sqlite') {
|
||||||
|
|
||||||
$date_interval = "date(date, '+1 day')";
|
$date_interval = "date(date, '+1 day')";
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::table(DB::raw("(
|
// Default CTE time series query (for MySQL and SQLite)
|
||||||
WITH RECURSIVE date_series AS (
|
$timeSeriesQuery = DB::table(DB::raw("(
|
||||||
SELECT '{$start_date->format('Y-m-d')}' AS date
|
WITH RECURSIVE date_series AS (
|
||||||
UNION ALL
|
SELECT '{$start_date->format('Y-m-d')}' AS date
|
||||||
SELECT $date_interval
|
UNION ALL
|
||||||
FROM date_series
|
SELECT $date_interval
|
||||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
|
||||||
)
|
|
||||||
SELECT date_series.date
|
|
||||||
FROM date_series
|
FROM date_series
|
||||||
) as date_series")
|
WHERE date < '{$end_date->format('Y-m-d')}'
|
||||||
)
|
)
|
||||||
->select([
|
SELECT date_series.date
|
||||||
'date_series.date',
|
FROM date_series
|
||||||
DB::raw("
|
) as date_series"));
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0) AS `owned`
|
// PGSql time series query
|
||||||
"),
|
if (config('database.default') === 'pgsql') {
|
||||||
DB::raw("
|
|
||||||
COALESCE(CASE
|
$timeSeriesQuery = DB::table(DB::raw("
|
||||||
WHEN (
|
generate_series(
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
date '{$start_date->format('Y-m-d')}',
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0)
|
date '{$end_date->format('Y-m-d')}',
|
||||||
) = 0 THEN 0
|
interval '1 day'
|
||||||
ELSE SUM(CASE
|
) as date_series"));
|
||||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
|
||||||
ELSE 0
|
$castNumberType = 'numeric';
|
||||||
END)
|
}
|
||||||
END, 0) AS cost_basis
|
|
||||||
"),
|
// Set MySQL-like query CTE max iterations
|
||||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`")
|
if (config('database.default') === 'mysql') {
|
||||||
])
|
|
||||||
->leftJoin('transactions', function ($join) {
|
// MySQL default
|
||||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
$max_recursion_var_name = 'cte_max_recursion_depth';
|
||||||
->where('transactions.symbol', '=', $this->symbol)
|
|
||||||
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
// Determine if running MySQL or MariaDB
|
||||||
})
|
$versionString = Arr::get(
|
||||||
->groupBy('date_series.date')
|
DB::select('SELECT VERSION() as version;'),
|
||||||
->orderBy('date_series.date')
|
'0', new \stdClass
|
||||||
->get()
|
)->version;
|
||||||
->keyBy('date');
|
if (stripos($versionString, 'MariaDB') !== false) {
|
||||||
|
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("SET $max_recursion_var_name=1000000;");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted query for counting QTY owned
|
||||||
|
$quantityQuery = "ROUND(CAST(COALESCE(
|
||||||
|
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
|
||||||
|
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
|
||||||
|
0
|
||||||
|
) AS {$castNumberType}), 3)";
|
||||||
|
|
||||||
|
return $timeSeriesQuery
|
||||||
|
->select([
|
||||||
|
'date_series.date',
|
||||||
|
DB::raw("
|
||||||
|
{$quantityQuery} AS owned
|
||||||
|
"),
|
||||||
|
DB::raw("
|
||||||
|
CASE
|
||||||
|
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||||
|
ELSE SUM(CASE
|
||||||
|
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
END AS cost_basis
|
||||||
|
"),
|
||||||
|
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS realized_gains"),
|
||||||
|
])
|
||||||
|
->leftJoin('transactions', function ($join) {
|
||||||
|
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||||
|
->where('transactions.symbol', '=', $this->symbol)
|
||||||
|
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
||||||
|
})
|
||||||
|
->groupBy('date_series.date')
|
||||||
|
->orderBy('date_series.date')
|
||||||
|
->get()
|
||||||
|
->keyBy('date');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function getFormattedTransactions()
|
||||||
|
{
|
||||||
|
$formattedTransactions = '';
|
||||||
|
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||||
|
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
|
||||||
|
.' '.$transaction->transaction_type
|
||||||
|
.' '.$transaction->quantity
|
||||||
|
.' @ '.$transaction->cost_basis
|
||||||
|
." each \n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formattedTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class MarketData extends Model
|
class MarketData extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $primaryKey = 'symbol';
|
protected $primaryKey = 'symbol';
|
||||||
|
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -25,7 +29,7 @@ class MarketData extends Model
|
|||||||
'market_cap',
|
'market_cap',
|
||||||
'book_value',
|
'book_value',
|
||||||
'last_dividend_date',
|
'last_dividend_date',
|
||||||
'dividend_yield'
|
'dividend_yield',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -37,10 +41,10 @@ class MarketData extends Model
|
|||||||
'trailing_pe' => 'float',
|
'trailing_pe' => 'float',
|
||||||
'market_cap' => 'float',
|
'market_cap' => 'float',
|
||||||
'book_value' => 'float',
|
'book_value' => 'float',
|
||||||
'dividend_yield' => 'float'
|
'dividend_yield' => 'float',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function holdings()
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -50,19 +54,20 @@ class MarketData extends Model
|
|||||||
return $query->where('symbol', $symbol);
|
return $query->where('symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getMarketData($symbol)
|
public static function getMarketData($symbol, $force = false)
|
||||||
{
|
{
|
||||||
$market_data = self::firstOrNew([
|
$market_data = self::firstOrNew([
|
||||||
'symbol' => $symbol
|
'symbol' => $symbol,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// check if new or stale
|
// check if new or stale
|
||||||
if (
|
if (
|
||||||
!$market_data->exists
|
$force
|
||||||
|
|| ! $market_data->exists
|
||||||
|| is_null($market_data->updated_at)
|
|| is_null($market_data->updated_at)
|
||||||
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
|
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// get quote
|
// get quote
|
||||||
$quote = app(MarketDataInterface::class)->quote($symbol);
|
$quote = app(MarketDataInterface::class)->quote($symbol);
|
||||||
|
|
||||||
@@ -75,4 +80,4 @@ class MarketData extends Model
|
|||||||
|
|
||||||
return $market_data;
|
return $market_data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+192
-58
@@ -1,13 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Notifications\InvitedOnboardingNotification;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Portfolio extends Model
|
class Portfolio extends Model
|
||||||
{
|
{
|
||||||
@@ -20,34 +26,36 @@ class Portfolio extends Model
|
|||||||
'wishlist',
|
'wishlist',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static ?string $owner_id = null;
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::saved(function ($model) {
|
|
||||||
|
|
||||||
self::syncUsers($model);
|
static::saved(function ($portfolio) {
|
||||||
|
|
||||||
|
self::ensurePortfolioHasOwner($portfolio);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'wishlist' => 'boolean'
|
'wishlist' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $with = ['users', 'transactions'];
|
protected $with = ['users', 'transactions'];
|
||||||
|
|
||||||
public function users()
|
public function users()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class)->withPivot('owner');
|
return $this->belongsToMany(User::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function holdings()
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'portfolio_id')
|
return $this->hasMany(Holding::class, 'portfolio_id')
|
||||||
->withMarketData()
|
->withMarketData()
|
||||||
->withPerformance();
|
->withPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions()
|
public function transactions()
|
||||||
@@ -60,91 +68,137 @@ class Portfolio extends Model
|
|||||||
return $this->hasMany(DailyChange::class);
|
return $this->hasMany(DailyChange::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyPortfolios()
|
/**
|
||||||
|
* Related chats for portfolio
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function chats()
|
||||||
|
{
|
||||||
|
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeMyPortfolios()
|
||||||
{
|
{
|
||||||
return $this->whereHas('users', function ($query) {
|
return $this->whereHas('users', function ($query) {
|
||||||
$query->where('user_id', auth()->user()->id);
|
$query->where('user_id', auth()->user()->id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists()
|
public function scopeFullAccess($query, $user_id = null)
|
||||||
|
{
|
||||||
|
return $query->whereHas('users', function ($query) use ($user_id) {
|
||||||
|
$query->where('user_id', $user_id ?? auth()->user()->id)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('full_access', true)
|
||||||
|
->orWhere('owner', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithoutWishlists()
|
||||||
{
|
{
|
||||||
return $this->where(['wishlist' => false]);
|
return $this->where(['wishlist' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOwnerIdAttribute()
|
public function setOwnerIdAttribute($value)
|
||||||
{
|
{
|
||||||
return $this->users()->firstWhere('owner', 1)?->id;
|
// enable queued jobs to create portfolios with owners
|
||||||
|
if (! auth()->user()?->id && ! $this->owner_id) {
|
||||||
|
static::$owner_id = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function syncUsers(self $model)
|
public function getOwnerIdAttribute()
|
||||||
|
{
|
||||||
|
return $this->owner?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOwnerAttribute()
|
||||||
|
{
|
||||||
|
if (! $this->relationLoaded('user')) {
|
||||||
|
|
||||||
|
$this->load('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->users->where('pivot.owner', true)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensurePortfolioHasOwner(self $portfolio)
|
||||||
{
|
{
|
||||||
// make sure we don't remove owner access
|
// make sure we don't remove owner access
|
||||||
$user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true];
|
if (! $portfolio->owner_id) {
|
||||||
|
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
|
||||||
|
|
||||||
// // add other users
|
// save
|
||||||
// foreach(request()->users ?? [] as $id) {
|
$portfolio->users()->sync($owner);
|
||||||
// $user_id[$id] = ['owner' => false];
|
static::$owner_id = null;
|
||||||
// };
|
}
|
||||||
|
|
||||||
// save
|
|
||||||
$model->users()->sync($user_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncDailyChanges(): void
|
public function syncDailyChanges(): void
|
||||||
{
|
{
|
||||||
$holdings = $this->holdings()
|
$holdings = $this->holdings()
|
||||||
->join('transactions', function($join) {
|
->join('transactions', function ($join) {
|
||||||
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
||||||
->where('transactions.portfolio_id', '=', $this->id);
|
->where('transactions.portfolio_id', '=', $this->id);
|
||||||
})
|
})
|
||||||
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
||||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
||||||
|
|
||||||
$total_performance = [];
|
$total_performance = [];
|
||||||
|
|
||||||
$holdings->each(function($holding) use (&$total_performance, $dividends) {
|
$holdings->each(function ($holding) use (&$total_performance, $dividends) {
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create(
|
||||||
|
$holding->first_transaction_date,
|
||||||
|
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||||
|
? now()->subDay()
|
||||||
|
: now()
|
||||||
|
);
|
||||||
|
|
||||||
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
||||||
|
|
||||||
|
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
||||||
|
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
|
||||||
|
return $dividend['date']->format('Y-m-d');
|
||||||
|
});
|
||||||
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
||||||
|
|
||||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
|
||||||
|
|
||||||
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
|
|
||||||
return $dividend['date']->format('Y-m-d');
|
|
||||||
});
|
|
||||||
|
|
||||||
$dividends_earned = 0;
|
$dividends_earned = 0;
|
||||||
$daily = [];
|
$holding_performance = [];
|
||||||
|
|
||||||
$all_history->sortBy('date')->each(function ($history, $date) use ($daily_performance, $dividends, &$daily, &$dividends_earned) {
|
foreach ($period as $date) {
|
||||||
|
$date = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||||
|
|
||||||
$close = Arr::get($history, 'close', 0);
|
|
||||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
$total_market_value = $daily_performance->get($date)->owned * $close;
|
||||||
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
||||||
|
|
||||||
$daily[$date] = [
|
if (Carbon::parse($date)->isWeekday()) {
|
||||||
'date' => $date,
|
$holding_performance[$date] = [
|
||||||
'portfolio_id' => $this->id,
|
'date' => $date,
|
||||||
'total_market_value' => $total_market_value,
|
'portfolio_id' => $this->id,
|
||||||
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
|
'total_market_value' => $total_market_value,
|
||||||
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
|
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
|
||||||
'realized_gains' => $daily_performance->get($date)->realized_gains,
|
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
|
||||||
'total_dividends_earned' => $dividends_earned
|
'realized_gains' => $daily_performance->get($date)->realized_gains,
|
||||||
];
|
'total_dividends_earned' => $dividends_earned,
|
||||||
});
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holding_performance as $date => $performance) {
|
||||||
|
if (Arr::get($total_performance, $date) == null) {
|
||||||
|
|
||||||
foreach ($daily as $date => $performance) {
|
|
||||||
if (!isset($total_performance[$date])) {
|
|
||||||
|
|
||||||
$total_performance[$date] = $performance;
|
$total_performance[$date] = $performance;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
||||||
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
||||||
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
||||||
@@ -154,12 +208,92 @@ class Portfolio extends Model
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!empty($total_performance)) {
|
if (! empty($total_performance)) {
|
||||||
DB::transaction(function () use ($total_performance) {
|
DB::transaction(function () use ($total_performance) {
|
||||||
$this->daily_change()->delete();
|
|
||||||
|
|
||||||
DailyChange::insert($total_performance);
|
// delete old history
|
||||||
|
$firstDate = array_keys($total_performance)[0];
|
||||||
|
$this->daily_change()->where('date', '<', $firstDate)->delete();
|
||||||
|
|
||||||
|
// upsert new history
|
||||||
|
$this->daily_change()->upsert(
|
||||||
|
$total_performance,
|
||||||
|
['date', 'portfolio_id'],
|
||||||
|
[
|
||||||
|
'total_market_value',
|
||||||
|
'total_cost_basis',
|
||||||
|
'total_gain',
|
||||||
|
'realized_gains',
|
||||||
|
'total_dividends_earned',
|
||||||
|
]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
||||||
|
{
|
||||||
|
$close = Arr::get($history, "$date.close", 0);
|
||||||
|
|
||||||
|
if (! $close && $i < $max_attempts) {
|
||||||
|
|
||||||
|
$i++;
|
||||||
|
|
||||||
|
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
|
||||||
|
|
||||||
|
return $this->getMostRecentCloseData($history, $date, $i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $close;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedHoldings()
|
||||||
|
{
|
||||||
|
$formattedHoldings = '';
|
||||||
|
foreach ($this->holdings as $holding) {
|
||||||
|
$formattedHoldings .= ' * Holding of '.$holding->market_data->name.' ('.$holding->symbol.')'
|
||||||
|
.'; with '.($holding->quantity > 0 ? $holding->quantity : 'ZERO').' shares'
|
||||||
|
.'; avg cost basis '.$holding->average_cost_basis
|
||||||
|
.'; curr market value '.$holding->market_data->market_value
|
||||||
|
.'; unrealized gains '.$holding->market_gain_dollars
|
||||||
|
.'; realized gains '.$holding->realized_gain_dollars
|
||||||
|
.'; dividends earned '.$holding->dividends_earned
|
||||||
|
."\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formattedHoldings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a portfolio with a user
|
||||||
|
*/
|
||||||
|
public function share(string $email, bool $fullAccess = false): void
|
||||||
|
{
|
||||||
|
$user = User::firstOrCreate([
|
||||||
|
'email' => $email,
|
||||||
|
], [
|
||||||
|
'name' => Str::title(Str::before($email, '@')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$permissions[$user->id] = [
|
||||||
|
'full_access' => $fullAccess,
|
||||||
|
];
|
||||||
|
|
||||||
|
$sync = $this->users()->syncWithoutDetaching($permissions);
|
||||||
|
|
||||||
|
if (! empty($sync['attached'])) {
|
||||||
|
|
||||||
|
foreach ($sync['attached'] as $newUserId) {
|
||||||
|
User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-share a portfolio
|
||||||
|
*/
|
||||||
|
public function unShare(string $userId): void
|
||||||
|
{
|
||||||
|
$this->users()->detach($userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-38
@@ -1,14 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Split extends Model
|
class Split extends Model
|
||||||
{
|
{
|
||||||
@@ -28,22 +29,23 @@ class Split extends Model
|
|||||||
'last_date' => 'datetime',
|
'last_date' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function holdings() {
|
public function holdings()
|
||||||
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions() {
|
public function transactions()
|
||||||
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grab new split data
|
* Grab new split data
|
||||||
*
|
*
|
||||||
* @param string $symbol
|
* @param \DateTimeInterface|null $start_date
|
||||||
* @param \DateTimeInterface|null $start_date
|
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function refreshSplitData(string $symbol)
|
public static function refreshSplitData(string $symbol)
|
||||||
{
|
{
|
||||||
// dates for split data
|
// dates for split data
|
||||||
$splits_meta = self::where(['symbol' => $symbol])
|
$splits_meta = self::where(['symbol' => $symbol])
|
||||||
@@ -58,9 +60,9 @@ class Split extends Model
|
|||||||
|
|
||||||
// nope, need to populate newer split data
|
// nope, need to populate newer split data
|
||||||
if ($splits_meta->total_splits) {
|
if ($splits_meta->total_splits) {
|
||||||
|
|
||||||
$start_date = $splits_meta->last_date->addHours(48);
|
$start_date = $splits_meta->last_date->addHours(48);
|
||||||
$end_date = now();
|
$end_date = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// get some data
|
// get some data
|
||||||
@@ -71,10 +73,10 @@ class Split extends Model
|
|||||||
if ($split_data->isNotEmpty()) {
|
if ($split_data->isNotEmpty()) {
|
||||||
|
|
||||||
// insert records
|
// insert records
|
||||||
(new self)->insert($split_data->map(function($split) {
|
(new self)->insert($split_data->map(function ($split) {
|
||||||
|
|
||||||
return [...$split, ...['id' => Str::uuid()->toString()]];
|
return [...$split, ...['id' => Str::uuid()->toString()]];
|
||||||
})->toArray());
|
})->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
// sync to transactions
|
// sync to transactions
|
||||||
@@ -84,39 +86,39 @@ class Split extends Model
|
|||||||
/**
|
/**
|
||||||
* Syncs all transactions of symbol with split data
|
* Syncs all transactions of symbol with split data
|
||||||
*
|
*
|
||||||
* @param string $symbol
|
* @param string $symbol
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function syncToTransactions($symbol)
|
public static function syncToTransactions($symbol)
|
||||||
{
|
{
|
||||||
// get splits joined with matching holdings
|
// get splits joined with matching holdings
|
||||||
$splits = self::select([
|
$splits = self::select([
|
||||||
'splits.date',
|
'splits.date',
|
||||||
'splits.symbol',
|
'splits.symbol',
|
||||||
'splits.split_amount',
|
'splits.split_amount',
|
||||||
'holdings.portfolio_id'
|
'holdings.portfolio_id',
|
||||||
])
|
])
|
||||||
->where([
|
->where([
|
||||||
'splits.symbol' => $symbol,
|
'splits.symbol' => $symbol,
|
||||||
])
|
])
|
||||||
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
|
->whereDate('splits.date', '>', DB::raw("COALESCE(holdings.splits_synced_at, '1901-01-01')"))
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
||||||
->orderBy('splits.date', 'ASC')
|
->orderBy('splits.date', 'ASC')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach($splits as $split) {
|
foreach ($splits as $split) {
|
||||||
|
|
||||||
// get qty owned when split was issued
|
// get qty owned when split was issued
|
||||||
$qty_owned = Transaction::where([
|
$qty_owned = Transaction::where([
|
||||||
'symbol' => $split->symbol,
|
'symbol' => $split->symbol,
|
||||||
'portfolio_id' => $split->portfolio_id
|
'portfolio_id' => $split->portfolio_id,
|
||||||
])
|
])
|
||||||
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
|
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) -
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) -
|
||||||
SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS qty_owned')
|
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
|
||||||
->value('qty_owned');
|
->value('qty_owned');
|
||||||
|
|
||||||
if ($qty_owned > 0) {
|
if ($qty_owned > 0) {
|
||||||
|
|
||||||
Transaction::create([
|
Transaction::create([
|
||||||
@@ -128,14 +130,14 @@ class Split extends Model
|
|||||||
'cost_basis' => 0,
|
'cost_basis' => 0,
|
||||||
'split' => true,
|
'split' => true,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now()
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Holding::where([
|
Holding::where([
|
||||||
'symbol' => $split->symbol,
|
'symbol' => $split->symbol,
|
||||||
'portfolio_id' => $split->portfolio_id
|
'portfolio_id' => $split->portfolio_id,
|
||||||
])->update([
|
])->update([
|
||||||
'splits_synced_at' => now()
|
'splits_synced_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-32
@@ -1,13 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\MarketData;
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class Transaction extends Model
|
class Transaction extends Model
|
||||||
{
|
{
|
||||||
@@ -23,6 +27,7 @@ class Transaction extends Model
|
|||||||
'cost_basis',
|
'cost_basis',
|
||||||
'sale_price',
|
'sale_price',
|
||||||
'split',
|
'split',
|
||||||
|
'reinvested_dividend',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
@@ -30,6 +35,7 @@ class Transaction extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'split' => 'boolean',
|
'split' => 'boolean',
|
||||||
|
'reinvested_dividend' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@@ -50,14 +56,14 @@ class Transaction extends Model
|
|||||||
|
|
||||||
$transaction->refreshMarketData();
|
$transaction->refreshMarketData();
|
||||||
|
|
||||||
cache()->tags(['metrics', $transaction->portfolio_id])->flush();
|
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
static::deleted(function ($transaction) {
|
static::deleted(function ($transaction) {
|
||||||
|
|
||||||
$transaction->syncToHolding();
|
$transaction->syncToHolding();
|
||||||
|
|
||||||
cache()->tags(['metrics', $transaction->portfolio_id])->flush();
|
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ class Transaction extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function market_data()
|
public function market_data(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -86,47 +92,47 @@ class Transaction extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function portfolio()
|
public function portfolio(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithMarketData($query)
|
public function scopeWithMarketData($query): Builder
|
||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio): Builder
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSymbol($query, $symbol)
|
public function scopeSymbol($query, $symbol): Builder
|
||||||
{
|
{
|
||||||
return $query->where('symbol', $symbol);
|
return $query->where('symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBuy($query)
|
public function scopeBuy($query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('transaction_type', 'BUY');
|
return $query->where('transaction_type', 'BUY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSell($query)
|
public function scopeSell($query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('transaction_type', 'SELL');
|
return $query->where('transaction_type', 'SELL');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBeforeDate($query, $date)
|
public function scopeBeforeDate($query, $date): Builder
|
||||||
{
|
{
|
||||||
return $query->whereDate('date', '<', $date);
|
return $query->whereDate('date', '<=', $date);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyTransactions()
|
public function scopeMyTransactions(): Builder
|
||||||
{
|
{
|
||||||
return $this->whereHas('portfolio', function ($query) {
|
return $this->whereHas('portfolio', function ($query) {
|
||||||
$query->whereHas('users', function ($query) {
|
$query->whereHas('users', function ($query) {
|
||||||
@@ -135,24 +141,22 @@ class Transaction extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshMarketData()
|
public function refreshMarketData(): void
|
||||||
{
|
{
|
||||||
return MarketData::getMarketData($this->attributes['symbol']);
|
MarketData::getMarketData($this->attributes['symbol']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes average cost basis to a sale transaction
|
* Writes average cost basis to a sale transaction
|
||||||
*
|
|
||||||
* @return Transaction
|
|
||||||
*/
|
*/
|
||||||
public function ensureCostBasisIsAddedToSale()
|
public function ensureCostBasisIsAddedToSale(): Transaction
|
||||||
{
|
{
|
||||||
$average_cost_basis = Transaction::where([
|
$average_cost_basis = Transaction::where([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
])->whereDate('date', '<=', $this->date)
|
])->whereDate('date', '<=', $this->date)
|
||||||
->average('cost_basis');
|
->average('cost_basis');
|
||||||
|
|
||||||
$this->cost_basis = $average_cost_basis ?? 0;
|
$this->cost_basis = $average_cost_basis ?? 0;
|
||||||
|
|
||||||
@@ -161,10 +165,9 @@ class Transaction extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs the holding related to this transaction
|
* Syncs the holding related to this transaction
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function syncToHolding() {
|
public function syncToHolding(): void
|
||||||
|
{
|
||||||
|
|
||||||
// if symbol name changed, sync previous symbol too
|
// if symbol name changed, sync previous symbol too
|
||||||
if (Arr::has($this->changes, 'symbol')) {
|
if (Arr::has($this->changes, 'symbol')) {
|
||||||
@@ -179,7 +182,7 @@ class Transaction extends Model
|
|||||||
// get the holding for a symbol and portfolio (or create one)
|
// get the holding for a symbol and portfolio (or create one)
|
||||||
Holding::firstOrNew([
|
Holding::firstOrNew([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol
|
'symbol' => $this->symbol,
|
||||||
], [
|
], [
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
@@ -189,4 +192,4 @@ class Transaction extends Model
|
|||||||
'splits_synced_at' => now(),
|
'splits_synced_at' => now(),
|
||||||
])->syncTransactionsAndDividends();
|
])->syncTransactionsAndDividends();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-14
@@ -1,28 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Traits\HasConnectedAccounts;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Jetstream\HasProfilePhoto;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||||
|
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasApiTokens;
|
use HasApiTokens;
|
||||||
|
use HasConnectedAccounts;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use HasProfilePhoto;
|
use HasProfilePhoto;
|
||||||
|
use HasRelationships;
|
||||||
|
use HasUuids;
|
||||||
use Notifiable;
|
use Notifiable;
|
||||||
use TwoFactorAuthenticatable;
|
use TwoFactorAuthenticatable;
|
||||||
use HasUuids;
|
|
||||||
use HasRelationships;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@@ -31,6 +34,7 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
|
'admin',
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
'two_factor_recovery_codes',
|
'two_factor_recovery_codes',
|
||||||
@@ -51,7 +55,7 @@ class User extends Authenticatable
|
|||||||
|
|
||||||
public function portfolios()
|
public function portfolios()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Portfolio::class)->withPivot('owner');
|
return $this->belongsToMany(Portfolio::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function daily_changes()
|
public function daily_changes()
|
||||||
@@ -63,7 +67,7 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
|
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
|
||||||
->withMarketData()
|
->withMarketData()
|
||||||
->withPerformance();
|
->withPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions(): HasManyDeep
|
public function transactions(): HasManyDeep
|
||||||
@@ -76,6 +80,6 @@ class User extends Authenticatable
|
|||||||
WHEN transaction_type = \'SELL\'
|
WHEN transaction_type = \'SELL\'
|
||||||
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
||||||
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||||
END AS gain_dollars');
|
END AS gain_dollars');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ImportFailedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $errorMessage
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->greeting('Oh no!')
|
||||||
|
->subject('Your Investbrain import failed!')
|
||||||
|
->line('Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.')
|
||||||
|
->action('Try again?', route('import-export'))
|
||||||
|
->line('**Technical details:**')
|
||||||
|
->line($this->errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ImportSucceededNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->greeting('Woot! 🎉')
|
||||||
|
->subject('Your Investbrain import was successful!')
|
||||||
|
->line('Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.')
|
||||||
|
->action('Get Started', route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Portfolio $portfolio,
|
||||||
|
public User $sender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
|
||||||
|
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->replyTo($this->sender->email, $this->sender->name)
|
||||||
|
->greeting('Hey there! 👋')
|
||||||
|
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
|
||||||
|
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
|
||||||
|
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
|
||||||
|
->action('Get Started', $url)
|
||||||
|
->line('If you have any questions, you can reply to this email.')
|
||||||
|
->salutation("See you there,\n".e($this->sender->name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $connected_account_id
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$connected_account = ConnectedAccount::find($this->connected_account_id);
|
||||||
|
$provider = config("services.$connected_account->provider.name");
|
||||||
|
|
||||||
|
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->greeting('Welcome back!')
|
||||||
|
->subject("Connect your $provider account with Investbrain")
|
||||||
|
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
||||||
|
->action("Connect $provider", $url)
|
||||||
|
->line('If you do not recognize this activity, we recommend [changing your password]('.route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PortfolioPolicy
|
||||||
|
{
|
||||||
|
public function readOnly(User $user, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
return (bool) $pivot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fullAccess(User $user, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function owner(User $user, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
return $pivot && $pivot->pivot->owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -22,6 +25,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
JsonResource::withoutWrapping();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Actions\Fortify\CreateNewUser;
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Actions\Jetstream\DeleteUser;
|
use App\Actions\Jetstream\DeleteUser;
|
||||||
@@ -25,7 +27,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
$this->configurePermissions();
|
$this->configurePermissions();
|
||||||
|
|
||||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
Jetstream::deleteUsersUsing(DeleteUser::class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,13 +34,22 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
protected function configurePermissions(): void
|
protected function configurePermissions(): void
|
||||||
{
|
{
|
||||||
Jetstream::defaultApiTokenPermissions(['read']);
|
Jetstream::defaultApiTokenPermissions([
|
||||||
|
// 'portfolio:read',
|
||||||
|
// 'portfolio:write',
|
||||||
|
// 'holding:read',
|
||||||
|
// 'holding:write',
|
||||||
|
// 'transaction:read',
|
||||||
|
// 'transaction:write',
|
||||||
|
]);
|
||||||
|
|
||||||
Jetstream::permissions([
|
Jetstream::permissions([
|
||||||
'create',
|
// 'Read Portfolios' => 'portfolio:read',
|
||||||
'read',
|
// 'Create Portfolios' => 'portfolio:write',
|
||||||
'update',
|
// 'Read Holdings' => 'holding:read',
|
||||||
'delete',
|
// 'Update Holdings' => 'holding:write',
|
||||||
|
// 'Read Transactions' => 'transaction:read',
|
||||||
|
// 'Create Transactions' => 'transaction:write',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Rules;
|
namespace App\Rules;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class QuantityValidationRule implements ValidationRule
|
class QuantityValidationRule implements ValidationRule
|
||||||
{
|
{
|
||||||
@@ -13,44 +16,44 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected Portfolio $portfolio,
|
protected ?Portfolio $portfolio,
|
||||||
protected string $symbol,
|
protected ?string $symbol,
|
||||||
protected string $transactionType,
|
protected ?string $transactionType,
|
||||||
protected string $date
|
protected string|Carbon|null $date
|
||||||
) {
|
) {
|
||||||
$this->portfolio = $portfolio;
|
$this->portfolio = $portfolio;
|
||||||
$this->symbol = $symbol;
|
$this->symbol = $symbol;
|
||||||
$this->transactionType = $transactionType;
|
$this->transactionType = $transactionType;
|
||||||
$this->date = $date;
|
$this->date = $date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the attribute.
|
* Validate the attribute.
|
||||||
*
|
|
||||||
* @param string $attribute
|
|
||||||
* @param mixed $value
|
|
||||||
* @param \Closure $fail
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
||||||
{
|
{
|
||||||
|
if (is_null($this->portfolio) || is_null($this->symbol) || is_null($this->transactionType) || is_null($this->date)) {
|
||||||
|
//
|
||||||
|
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->transactionType == 'SELL') {
|
if ($this->transactionType == 'SELL') {
|
||||||
|
|
||||||
$purchase_qty = $this->portfolio->transactions()
|
$purchase_qty = $this->portfolio->transactions()
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->buy()
|
->buy()
|
||||||
->beforeDate($this->date)
|
->beforeDate($this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$sales_qty = $this->portfolio->transactions()
|
$sales_qty = $this->portfolio->transactions()
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->sell()
|
->sell()
|
||||||
->beforeDate($this->date)
|
->beforeDate($this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$maxQuantity = $purchase_qty - $sales_qty;
|
$maxQuantity = $purchase_qty - $sales_qty;
|
||||||
|
|
||||||
if ($value > $maxQuantity) {
|
if (round($value, 3) > round($maxQuantity, 3)) {
|
||||||
$fail(__('The quantity must not be greater than the available quantity.'));
|
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Rules;
|
namespace App\Rules;
|
||||||
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
@@ -22,24 +24,20 @@ class SymbolValidationRule implements ValidationRule
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the attribute.
|
* Validate the attribute.
|
||||||
*
|
|
||||||
* @param string $attribute
|
|
||||||
* @param mixed $value
|
|
||||||
* @param \Closure $fail
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
public function validate(string $attribute, mixed $value, \Closure $fail): void
|
||||||
{
|
{
|
||||||
$this->symbol = $value;
|
$this->symbol = $value;
|
||||||
|
|
||||||
|
// Check if the symbol exists in the Market Data table first (avoid API call)
|
||||||
if (MarketData::find($this->symbol)) {
|
if (MarketData::find($this->symbol)) {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the symbol exists in the Market Data table first (avoid API call)
|
// Then check against market data provider
|
||||||
if (!app(MarketDataInterface::class)->exists($value)) {
|
if (! app(MarketDataInterface::class)->exists($value)) {
|
||||||
$fail('The symbol provided (' . $this->symbol . ') is not valid');
|
$fail('The symbol provided ('.$this->symbol.') is not valid');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
// if (!function_exists('formatMoney')) {
|
// if (!function_exists('formatMoney')) {
|
||||||
// /**
|
// /**
|
||||||
// * Returns a formatted string for currency
|
// * Returns a formatted string for currency
|
||||||
|
|||||||
+14
-14
@@ -1,55 +1,55 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use App\Models\Holding;
|
|
||||||
use App\Models\Portfolio;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class Spotlight
|
class Spotlight
|
||||||
{
|
{
|
||||||
public function search(Request $request)
|
public function search(Request $request)
|
||||||
{
|
{
|
||||||
|
|
||||||
$results = collect();
|
$results = collect();
|
||||||
|
|
||||||
if (!$request->user()) {
|
if (! $request->user()) {
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
$portfolios = $request->user()->portfolios()
|
$portfolios = $request->user()->portfolios()
|
||||||
->where('title', 'LIKE', '%'.$request->input('search').'%')
|
->whereFullText('title', $request->input('search'))
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
$portfolios->each(function($portfolio) use ($results) {
|
$portfolios->each(function ($portfolio) use ($results) {
|
||||||
|
|
||||||
$results->push([
|
$results->push([
|
||||||
'name' => 'Portfolio: '. $portfolio->title,
|
'name' => 'Portfolio: '.$portfolio->title,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'link' => route('portfolio.show', ['portfolio' => $portfolio->id]),
|
'link' => route('portfolio.show', ['portfolio' => $portfolio->id]),
|
||||||
'avatar' => null
|
'avatar' => null,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
$holdings = $request->user()->holdings()
|
$holdings = $request->user()->holdings()
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->where(function ($query) use ($request) {
|
->where(function ($query) use ($request) {
|
||||||
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
|
return $query->whereFullText('holdings.symbol', $request->input('search'))
|
||||||
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
|
->orWhereFullText('market_data.name', $request->input('search'));
|
||||||
})
|
})
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
$holdings->each(function($holding) use ($results) {
|
$holdings->each(function ($holding) use ($results) {
|
||||||
|
|
||||||
$results->push([
|
$results->push([
|
||||||
'name' => 'Holding: '.$holding->market_data->name.' ('.$holding->symbol.')',
|
'name' => 'Holding: '.$holding->market_data->name.' ('.$holding->symbol.')',
|
||||||
'description' => $holding->portfolio->title,
|
'description' => $holding->portfolio->title,
|
||||||
'link' => route('holding.show', ['portfolio' => $holding->portfolio->id, 'symbol' => $holding->symbol]),
|
'link' => route('holding.show', ['portfolio' => $holding->portfolio->id, 'symbol' => $holding->symbol]),
|
||||||
'avatar' => null
|
'avatar' => null,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Traits;
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
trait HasCompositePrimaryKey
|
trait HasCompositePrimaryKey
|
||||||
{
|
{
|
||||||
@@ -17,17 +19,18 @@ trait HasCompositePrimaryKey
|
|||||||
/**
|
/**
|
||||||
* Set the keys for a save update query.
|
* Set the keys for a save update query.
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||||
* @return \Illuminate\Database\Eloquent\Builder
|
* @return \Illuminate\Database\Eloquent\Builder
|
||||||
*/
|
*/
|
||||||
protected function setKeysForSaveQuery($query)
|
protected function setKeysForSaveQuery($query)
|
||||||
{
|
{
|
||||||
foreach ($this->getKeyName() as $key) {
|
foreach ($this->getKeyName() as $key) {
|
||||||
// UPDATE: Added isset() per devflow's comment.
|
// UPDATE: Added isset() per devflow's comment.
|
||||||
if (isset($this->$key))
|
if (isset($this->$key)) {
|
||||||
$query->where($key, '=', $this->$key);
|
$query->where($key, '=', $this->$key);
|
||||||
else
|
} else {
|
||||||
throw new \Exception(__METHOD__ . 'Missing part of the primary key: ' . $key);
|
throw new \Exception(__METHOD__.'Missing part of the primary key: '.$key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
@@ -37,7 +40,7 @@ trait HasCompositePrimaryKey
|
|||||||
/**
|
/**
|
||||||
* Execute a query for a single record by ID.
|
* Execute a query for a single record by ID.
|
||||||
*
|
*
|
||||||
* @param array $ids Array of keys, like [column => value].
|
* @param array $ids Array of keys, like [column => value].
|
||||||
* @param array $columns
|
* @param array $columns
|
||||||
* @return mixed|static
|
* @return mixed|static
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +51,7 @@ trait HasCompositePrimaryKey
|
|||||||
foreach ($me->getKeyName() as $key) {
|
foreach ($me->getKeyName() as $key) {
|
||||||
$query->where($key, '=', $ids[$key]);
|
$query->where($key, '=', $ids[$key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->first($columns);
|
return $query->first($columns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Collection $connectedAccounts
|
||||||
|
*/
|
||||||
|
trait HasConnectedAccounts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user owns the given connected account.
|
||||||
|
*/
|
||||||
|
public function ownsConnectedAccount(mixed $connectedAccount): bool
|
||||||
|
{
|
||||||
|
return $this->id == optional($connectedAccount)->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user has a specific account type.
|
||||||
|
*/
|
||||||
|
public function hasTokenFor(string $provider): bool
|
||||||
|
{
|
||||||
|
return $this->connectedAccounts->contains('provider', Str::lower($provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve the token for a given provider.
|
||||||
|
*/
|
||||||
|
public function getTokenFor(string $provider, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if ($this->hasTokenFor($provider)) {
|
||||||
|
return $this->connectedAccounts
|
||||||
|
->where('provider', Str::lower($provider))
|
||||||
|
->first()
|
||||||
|
->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to find a connected account that belongs to the user,
|
||||||
|
* for the given provider and ID.
|
||||||
|
*/
|
||||||
|
public function getConnectedAccountFor(string $provider, string $id): mixed
|
||||||
|
{
|
||||||
|
return $this->connectedAccounts
|
||||||
|
->where('provider', $provider)
|
||||||
|
->where('provider_id', $id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the connected accounts belonging to the user.
|
||||||
|
*/
|
||||||
|
public function connectedAccounts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ConnectedAccount::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait WithTrimStrings
|
||||||
|
{
|
||||||
|
public function trimExceptions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedWithTrimStrings(string $property, mixed $value): void
|
||||||
|
{
|
||||||
|
if (is_string($value) && ! in_array($property, $this->trimExceptions())) {
|
||||||
|
$this->fill([
|
||||||
|
$property => Str::trim($value),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\View\Components;
|
namespace App\View\Components;
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
use Illuminate\View\Component;
|
||||||
@@ -16,6 +18,7 @@ class AppLayout extends Component
|
|||||||
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
|
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<x-partials.nav-bar />
|
<x-partials.nav-bar />
|
||||||
|
|
||||||
<x-main with-nav full-width>
|
<x-main with-nav full-width>
|
||||||
@@ -27,11 +30,19 @@ class AppLayout extends Component
|
|||||||
</x-slot:sidebar>
|
</x-slot:sidebar>
|
||||||
|
|
||||||
<x-slot:content>
|
<x-slot:content>
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</x-slot:content>
|
</x-slot:content>
|
||||||
|
|
||||||
</x-main>
|
</x-main>
|
||||||
|
|
||||||
|
@if(session('toast'))
|
||||||
|
<script lang="text/javascript">
|
||||||
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
window.toast(JSON.parse(@json(session('toast'))))
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endif
|
||||||
<x-toast />
|
<x-toast />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\View\Components;
|
namespace App\View\Components;
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
use Illuminate\View\Component;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\View\Components;
|
namespace App\View\Components;
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
use Illuminate\View\Component;
|
||||||
@@ -11,7 +13,7 @@ class MainLayout extends Component
|
|||||||
|
|
||||||
// Slots
|
// Slots
|
||||||
public mixed $body = null,
|
public mixed $body = null,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the view / contents that represents the component.
|
* Get the view / contents that represents the component.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\SetLocale;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
|
|||||||
+16
-3
@@ -5,18 +5,25 @@
|
|||||||
"keywords": ["stocks", "dividends", "investments", "tracking"],
|
"keywords": ["stocks", "dividends", "investments", "tracking"],
|
||||||
"license": "CC-BY-NC 4.0",
|
"license": "CC-BY-NC 4.0",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
"finnhub/client": "master@dev",
|
"finnhub/client": "master@dev",
|
||||||
"laravel/framework": "^11.9",
|
"hackeresq/filter-models": "dev-main",
|
||||||
|
"laravel/framework": "^11.35",
|
||||||
"laravel/jetstream": "^5.1",
|
"laravel/jetstream": "^5.1",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
|
"laravel/socialite": "^5.16",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"livewire/livewire": "^3.5",
|
"livewire/livewire": "^3.5",
|
||||||
"livewire/volt": "^1.6",
|
"livewire/volt": "^1.6",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
|
"openai-php/client": "^0.10.3",
|
||||||
"predis/predis": "^2.2",
|
"predis/predis": "^2.2",
|
||||||
"robsontenorio/mary": "^1.35",
|
"robsontenorio/mary": "^1.35",
|
||||||
"scheb/yahoo-finance-api": "^4.10",
|
"scheb/yahoo-finance-api": "^4.11",
|
||||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||||
"tschucki/alphavantage-laravel": "^0.0"
|
"tschucki/alphavantage-laravel": "^0.0"
|
||||||
},
|
},
|
||||||
@@ -31,6 +38,12 @@
|
|||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
|
"no-api": true,
|
||||||
|
"url": "https://github.com/hackeresq/filter-models"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"no-api": true,
|
||||||
"url": "https://github.com/investbrainapp/finnhub-php"
|
"url": "https://github.com/investbrainapp/finnhub-php"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+2050
-772
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'key' => env('ALPHAVANTAGE_API_KEY'),
|
'key' => env('ALPHAVANTAGE_API_KEY'),
|
||||||
];
|
];
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -13,7 +15,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'Investbrain'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -98,7 +100,8 @@ return [
|
|||||||
|
|
||||||
'cipher' => 'AES-256-CBC',
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
'key' => env('APP_KEY'),
|
'key' => env('APP_KEY')
|
||||||
|
?: when(file_exists(storage_path('app/.key')), fn () => trim(file_get_contents(storage_path('app/.key')))),
|
||||||
|
|
||||||
'previous_keys' => [
|
'previous_keys' => [
|
||||||
...array_filter(
|
...array_filter(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user