Compare commits

...

345 Commits

Author SHA1 Message Date
hackerESQ 34223960f8 chore: Bump PHP version to 8.4
see #150
2025-11-06 21:10:53 -06:00
hackerESQ 5f583de857 fix: export daily change dates transposition
reference #148
2025-11-04 19:59:33 -06:00
hackerESQ bb0a0ef928 fix: date for transactions in api requests
references #148
2025-11-04 19:44:14 -06:00
Fexiven 2d4c7002a7 fix: Create nginx directory (#143)
fixes:

nginx: [emerg] open() "/run/nginx/nginx.pid" failed (2: No such file or directory)
2025-10-30 16:43:26 -05:00
hackerESQ 939e46eb61 fix: dividends should be cast to float 2025-10-09 19:02:45 -05:00
hackerESQ 04f1d8cbcd fix: transactions table 2025-10-06 20:01:29 -05:00
hackerESQ c6032c5b66 cleanup 2025-09-28 21:13:52 -05:00
hackerESQ 8908e2da02 Fix: mobile responsive with table 2025-09-28 19:54:49 -05:00
hackerESQ 892d5a30e0 fix: default filesystem name 2025-09-27 21:52:57 -05:00
hackerESQ b896513be9 fix: disable wire navigate for social login 2025-09-26 20:36:29 -05:00
hackerESQ 013ccba050 update system prompt 2025-09-26 20:11:51 -05:00
hackerESQ a10f94a570 revert 2025-09-26 20:03:39 -05:00
hackerESQ 5b8b9ae39e wip 2025-09-26 18:32:00 -05:00
hackerESQ 3e84ed7572 string currency 2025-09-26 18:20:00 -05:00
hackerESQ 39458ef44e optional currency 2025-09-26 18:16:17 -05:00
hackerESQ 0e47b7538e Merge branch 'main' of https://github.com/investbrainapp/investbrain 2025-09-26 17:54:46 -05:00
hackerESQ 0aaa51e736 Fix: touch log file during start up
fixes laravel.log permissions #137
2025-09-26 17:54:44 -05:00
hackerESQ e6f38d9481 Chore: Upgrade to Laravel 12 + remove Mary and Jetstream dependencies (#141)
* docs: remove requirement for setting APP_KEY manually

* optimize date picker

* clean up modals

* spot light working

* reorganization

* add lazy load

* wip

* remove filament

* styling
2025-09-26 17:41:28 -05:00
hackerESQ 910d426ad4 add test 2025-09-13 22:24:02 -05:00
hackerESQ 72ad02de4b fix: standardize currency 2025-09-13 22:24:02 -05:00
hackerESQ 50285a3d51 Update SECURITY.md 2025-09-05 21:00:48 -05:00
hackerESQ ff31e3d48b Delete .github/dependabot.yml 2025-09-05 18:34:01 -05:00
hackerESQ 3d944afeb4 auto-bump deps using dependabot 2025-09-05 18:24:15 -05:00
hackerESQ 8e625107c1 keep APP_KEY reference in configuration section 2025-09-05 11:53:26 -05:00
enterprised1 df034863c7 Remove references to add APP_KEY in README.md
The current instructions specify an APP_KEY needs to be manually generated and added to the environment properties.  However, doing so results in a 500 error and the following post mentions APP_KEY is now generated automatically.  

https://github.com/orgs/investbrainapp/discussions/74#discussioncomment-14269950
2025-09-05 11:53:26 -05:00
hackerESQ 70cdfc9fd8 Delete .shift 2025-09-01 21:32:25 -05:00
hackerESQ a0bd776abb fix: quantity validation should not count current transaction 2025-08-29 15:47:38 -05:00
hackerESQ afcafa6031 chore: upgrade deps 2025-08-28 21:56:28 -05:00
hackerESQ 07c85697f3 chore: upgrade deps 2025-08-28 21:56:05 -05:00
hackerESQ a882b5aadb chore: clean up 2025-08-28 21:26:11 -05:00
hackerESQ bad82fb41b chore: cleanup old files 2025-08-28 21:26:11 -05:00
Shift 5aca9008cb Add .shift to open Pull Request 2025-08-28 21:26:11 -05:00
hackerESQ 712a4c6c57 docs: further clarification 2025-08-28 20:58:25 -05:00
hackerESQ 78f0d21b73 docs: clarify commands and update intro 2025-08-28 18:08:28 -05:00
hackerESQ 19cac58692 Fix: do not gracefully fail when symbol not found 2025-08-28 16:03:02 -05:00
hackerESQ 7d77b6fbc8 feat: add fix command for cost basis for sale transaction 2025-08-27 20:22:13 -05:00
hackerESQ e4e08091af fix: need to chunk alpaca history requests 2025-08-27 20:03:25 -05:00
hackerESQ 292d43b154 feat: adds cost basis fixer 2025-08-26 23:03:23 -05:00
hackerESQ eae4422ad8 fix: alphavantage multiply by string 2025-08-26 19:29:10 -05:00
hackerESQ 53d463b8b5 chore: upgrade deps 2025-08-26 19:27:26 -05:00
hackerESQ 827644bb32 fix: yahoo driver 2025-08-26 19:13:00 -05:00
hackerESQ 21e8672a12 feat: add twelve data market data provider 2025-08-26 18:26:12 -05:00
hackerESQ 70910c2f6d docs: adds alpaca 2025-08-25 21:05:42 -05:00
hackerESQ 9ddea4c6e1 fix: add exception for 404 2025-08-25 21:00:49 -05:00
hackerESQ 576b22e4c9 fix: hard code USD 2025-08-25 21:00:49 -05:00
hackerESQ 0035879a87 feat: add alpaca provider 2025-08-25 21:00:49 -05:00
hackerESQ 97298bcd39 Delete holding if no related transactions
resolves #63
2025-08-25 20:23:59 -05:00
hackerESQ 0504058c01 fix: auth tests failing if env shows self hosted 2025-08-25 20:21:22 -05:00
hackerESQ 750ccbd68f fix: locale setting 2025-08-25 19:58:53 -05:00
hackerESQ d815700e58 fix: simplify logo 2025-08-25 19:39:55 -05:00
hackerESQ 9d809bbbe4 test loop once? 2025-08-22 20:49:12 -05:00
hackerESQ 74a26e004f round graph 2025-08-22 20:38:54 -05:00
hackerESQ 65710e2791 dividend earnings not shared between portfolios 2025-08-22 16:37:33 -05:00
hackerESQ ac310735df wip 2025-08-21 21:46:53 -05:00
hackerESQ 5611de0e2e cleanup 2025-08-21 21:09:52 -05:00
hackerESQ 4196539169 cleanup 2025-08-21 21:09:48 -05:00
hackerESQ 08cfcceb6a clean up capture daily change command 2025-08-21 21:04:56 -05:00
hackerESQ e427d5802c wip 2025-08-21 20:54:14 -05:00
hackerESQ fc5cc1fee2 wip 2025-08-21 20:12:59 -05:00
hackerESQ fb3c19d3bf wip 2025-08-21 19:51:48 -05:00
hackerESQ 24aeb72549 temp remove dividends 2025-08-20 18:31:15 -05:00
hackerESQ c799da58e1 qip 2025-08-19 21:54:18 -05:00
hackerESQ e24f932c0f wip 2025-08-19 21:47:27 -05:00
hackerESQ 7e2bf3430e wip 2025-08-19 21:34:35 -05:00
hackerESQ e1c8c2c515 wip 2025-08-11 21:51:54 -05:00
hackerESQ ae1e59ce30 wip 2025-08-11 21:21:16 -05:00
hackerESQ 03089ed1b3 wip 2025-08-11 20:39:54 -05:00
hackerESQ 97b13063d9 wip 2025-08-11 19:58:17 -05:00
hackerESQ 9260de5f25 wip 2025-08-05 21:43:55 -05:00
hackerESQ 505a24bf99 chore: clean up 2025-07-23 21:29:44 -05:00
hackerESQ 0e88b8c6f5 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2025-07-22 21:52:32 -05:00
hackerESQ 519486fe57 fix: settings for user localiation 2025-07-22 21:52:04 -05:00
hackerESQ 4086168515 fix: settings for user localiation 2025-07-22 21:51:54 -05:00
hackerESQ a13bd9f0dc fix: double counting cr 2025-07-22 20:20:59 -05:00
hackerESQ 2c3950b522 fix: holding calculations 2025-07-21 20:36:36 -05:00
hackerESQ 653f54add6 feat: adds today() method 2025-07-21 20:28:57 -05:00
hackerESQ 8e0d792d26 fix: calculate proper cost basis 2025-07-21 20:28:39 -05:00
hackerESQ 81af737204 fix: cost basis calculations on daily change queries 2025-07-17 22:00:30 -05:00
hackerESQ 81845d47f2 fix: cost basis for holding calculations 2025-07-17 20:38:29 -05:00
hackerESQ cf475657cf feat: add version number to docker image 2025-07-16 17:07:25 -05:00
hackerESQ 90a15ceddb fix: set default 2025-07-14 21:20:47 -05:00
hackerESQ 981ce0d62f fix: null coalesce 2025-07-14 21:20:25 -05:00
hackerESQ 154b679464 chore: update yahoo dep 2025-07-14 21:20:08 -05:00
hackerESQ ee51cb7e2a fix: division by zero error 2025-07-12 00:40:37 -05:00
hackerESQ 40120c7027 fix: delay queued currency rates filling 2025-07-11 22:38:09 -05:00
hackerESQ cfd5b8a4f3 feat: default to pgsql 2025-07-11 22:13:16 -05:00
hackerESQ 3b93e328d5 feat: fancy ascii art 2025-07-11 21:43:36 -05:00
hackerESQ 1fd858287d fix: clear and re-create caches 2025-07-11 21:42:11 -05:00
hackerESQ e370f5bbb7 fix: clear cache after every reload 2025-07-11 21:33:58 -05:00
hackerESQ 3e492475c0 fix: migrations failing on mysql 2025-07-09 21:55:32 -05:00
hackerESQ c454e85ad4 fix: date calculations cause failed tests 2025-07-09 19:37:51 -05:00
David Peng 487322abb5 fix: fix postgresql support (#100)
Fix #81
2025-07-09 19:11:25 -05:00
hackerESQ f78c521dc4 fix: add bp.l to test multicurr seed 2025-05-16 21:12:48 -05:00
hackerESQ ff9bcd782f fix: don't queue market data seed 2025-05-16 20:49:29 -05:00
hackerESQ 1ccf515ca2 fix: reorg migrtion 2025-05-16 19:59:39 -05:00
hackerESQ 1b0f9c134c fix: dispatch time series rates 2025-05-16 19:38:58 -05:00
hackerESQ 3589242996 fix: dispatch time series updates 2025-05-16 19:31:44 -05:00
hackerESQ 689aa4d50b fix: multi currency seeders 2025-05-15 20:05:14 -05:00
hackerESQ 26370c03c4 fix: optimize migration to multi-currency 2025-05-03 13:22:45 -05:00
hackerESQ 80b043219a prevent pre-releases from triggering image build 2025-05-02 20:07:38 -05:00
hackerESQ de54b6843d Fix multi-currency imports (#94) 2025-05-02 18:14:06 -05:00
hackerESQ 17e5d8b665 fix: increase chunk size 2025-04-12 10:12:30 -05:00
hackerESQ bd9c828c68 fix: use options prop 2025-04-11 21:49:06 -05:00
hackerESQ f72cd6f5a7 fix: set name attribute 2025-04-11 21:45:58 -05:00
hackerESQ 3593697cce fix: user needs to be set from import job 2025-04-11 21:42:38 -05:00
hackerESQ d53e71dcd5 Update README.md 2025-04-11 21:28:05 -05:00
hackerESQ 71e79cfb40 fix: daily change should be synced when before latest transaction 2025-04-11 21:14:53 -05:00
hackerESQ 38a65f99c9 fixes multi currency tests 2025-04-11 20:57:21 -05:00
hackerESQ 26e54fb357 chore: update deps 2025-04-10 21:36:40 -05:00
hackerESQ 224ed104b9 chore: fix deps 2025-04-10 21:33:18 -05:00
hackerESQ 2702fe27e4 chore: remove dev dep 2025-04-10 21:29:59 -05:00
hackerESQ dd21227f8f Feat: Adds multi currency support to API (#90) 2025-04-10 21:24:44 -05:00
hackerESQ 1ef8dd9378 Feat: Adds multi currency to imports and exports (#89)
* Also adds ability for user to export configurations
2025-04-10 20:47:35 -05:00
hackerESQ eae345f243 Feat: Adds multi currency support (#88) 2025-04-09 19:25:15 -05:00
hackerESQ 6d6f968f42 Merge pull request #76 from investbrainapp/dividend-splits-should-be-unique
fix: add unique constraint to split and dividends
2025-03-19 16:17:01 -05:00
hackerESQ 261c848ffd fix: add unique constraint to split and dividends
to prevent duplicate records
2025-03-19 16:16:38 -05:00
hackerESQ 9bcc80078e Update 2021_09_06_014744_create_holdings_table.php 2025-03-19 15:32:38 -05:00
hackerESQ c4b7d399ea Update SECURITY.md 2025-03-17 18:19:12 -05:00
hackerESQ ffe53e91c0 Merge pull request #75 from investbrainapp/simplify-asset-url
feat: simplify self host install by removing asset_url env
2025-03-17 18:18:32 -05:00
hackerESQ aeb1b12afe feat: simplify self host install by removing asset_url env 2025-03-17 18:18:12 -05:00
hackerESQ fe81ec7ee7 fix: adds reinvest column back to holdings table 2025-03-13 20:45:00 -05:00
hackerESQ f0ecc0fd3d fix: create profile photo disk for jetstream 2025-03-12 12:02:34 -05:00
hackerESQ 03b75fb683 adds sentry log driver 2025-03-11 17:55:51 -05:00
hackerESQ dc93621547 simplify example.env 2025-03-10 22:59:15 -05:00
hackerESQ 7ab6f79e56 feat: adds pgsql compatibility (#72) 2025-03-10 21:17:24 -05:00
hackerESQ 9e48f21c8d fix: better pgsql support 2025-03-07 19:30:06 -06:00
hackerESQ 10e6de8df4 chore: clean up market data seed 2025-03-07 19:15:10 -06:00
hackerESQ 00fbdec6f1 fix: improve seeder (and remove symbol dupes) 2025-03-07 18:43:55 -06:00
hackerESQ 730903c383 fix: compatible with pgsql 2025-03-07 17:45:54 -06:00
hackerESQ 5fc9455908 fix: longer exception 2025-03-07 17:27:08 -06:00
hackerESQ 28e0ad68fc fix: truncate exception so meaningful data shows first 2025-03-07 17:20:15 -06:00
hackerESQ ca48d702a7 chore: simplify .env file 2025-03-07 17:07:47 -06:00
hackerESQ 812b9ed075 chore: critical security update for livewire/volt 2025-03-06 19:51:06 -06:00
hackerESQ 93a0595652 Merge pull request #70 from investbrainapp/use-correct-storage-path
refactor: use correct storage path for app key check
2025-03-06 18:36:23 -06:00
hackerESQ 8a357e8cab refactor: use correct storage path for app key check 2025-03-06 18:35:57 -06:00
hackerESQ 22e12977f8 Merge pull request #69 from investbrainapp/save-app-key-to-file
refactor: storage scaffolding and save generated app key to file
2025-03-06 16:57:00 -06:00
hackerESQ 732cf02317 refactor: storage directory scaffoling and save generated app key to file 2025-03-06 16:55:54 -06:00
hackerESQ 6dea75651b fix: version response will be an object 2025-02-25 20:16:13 -06:00
Oscar Padilla 6cff252813 fix: support mariadb in sync:daily-change (#64)
* fix: support mariadb in sync:daily-change

* use version() instead of system variables

---------

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

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

* Removed runtime dependencies from the build stage.

* remove unneeded php extensions from stage 1 build step
2025-02-25 19:04:02 -06:00
hackerESQ a3f875270b fix: quiet redis logs 2025-02-01 10:52:15 -06:00
hackerESQ 00a1312ee3 fix: move storage:link to dockerfile 2025-01-30 19:16:33 -06:00
hackerESQ 1195faca0f docs: add more helpful comments 2025-01-30 19:00:55 -06:00
hackerESQ a39f255e52 fix: ensure permissions are set and storage dir is scaffolded 2025-01-30 18:48:34 -06:00
hackerESQ cac2460153 fix: predis always the default 2025-01-30 18:13:16 -06:00
hackerESQ 894da4ef9b fix: make redis default 2025-01-30 18:13:01 -06:00
hackerESQ a705b794fd docs: add note about "broken styling" 2025-01-29 23:13:28 -06:00
hackerESQ 37da6885ee fix: use laravel up health endpoint 2025-01-29 23:06:42 -06:00
hackerESQ 219018b1d9 Merge pull request #59 from investbrainapp/docker-permissions
fix: ensure storage path permissions are set in entry script
2025-01-29 22:54:08 -06:00
hackerESQ 4b780fd6d2 fix: ensure storage path permissions are set in entry script 2025-01-29 22:53:32 -06:00
hackerESQ 1faa22897b clean up 2025-01-28 21:16:51 -06:00
hackerESQ 7e1899d8ff fix: adds view only icon 2025-01-28 21:15:29 -06:00
hackerESQ 878c668696 Merge branch 'multi-curr-support' into main 2025-01-28 20:34:59 -06:00
hackerESQ 8c94fbf299 fix: ensure failed exists() is boolean 2025-01-28 20:33:28 -06:00
hackerESQ 4ece09368e fix: upgrade the exists() market data provider method 2025-01-28 20:32:43 -06:00
hackerESQ 0f135f4024 fix: gracefully fail if symbol not found 2025-01-28 19:48:20 -06:00
hackerESQ eac5de0d4a fix: adds appropriate return types 2025-01-28 19:46:37 -06:00
hackerESQ 399858d09b fix: strongly type symbol for market data and quote 2025-01-28 19:35:15 -06:00
hackerESQ 7694d8a241 Create 2025_12_28_000001_add_multi_currency_support.php 2025-01-28 19:34:49 -06:00
hackerESQ 9bd406c5b1 fix: adds appropriate return types 2025-01-28 19:03:06 -06:00
hackerESQ d23d28afd8 feat: prepare for api token capabilities 2025-01-28 18:20:03 -06:00
hackerESQ 0a6b2d844f fix: hide terms on self hosted 2025-01-28 18:06:03 -06:00
hackerESQ be325d31b6 chore: lint 2025-01-28 17:46:59 -06:00
hackerESQ e08c1880c6 chore: update deps 2025-01-28 17:46:42 -06:00
hackerESQ 5f9f6f01c5 fix: re-format token color 2025-01-28 17:41:05 -06:00
hackerESQ 65388238c3 fix: allow carbon as date for qty validation 2025-01-28 17:39:08 -06:00
hackerESQ cdce46b6df chore: add script type rule to pint 2025-01-28 17:33:54 -06:00
hackerESQ 8320b54332 fix: require valid email for import and api tokens 2025-01-28 17:28:01 -06:00
hackerESQ e8ef0921ad chore: code style 2025-01-28 17:14:49 -06:00
hackerESQ c4736fae70 chore: cleanup 2025-01-28 13:31:39 -06:00
hackerESQ 1748f49ee6 fix: remove test route 2025-01-28 13:31:02 -06:00
hackerESQ c32641ec34 fix: use requested symbol name for market data providers 2025-01-28 13:30:12 -06:00
hackerESQ 53ebe28b14 fix: makes portfolio available to form request 2025-01-27 23:08:23 -06:00
hackerESQ 465686dbaf fix: simplify the portfolio_id verification 2025-01-27 23:03:33 -06:00
hackerESQ 58604c1e5a chore: update deps 2025-01-27 22:44:03 -06:00
hackerESQ 3e4f055a4a trim size of market data seeder 2025-01-27 20:54:23 -06:00
hackerESQ 92586d7466 fix: update icon for api tokens 2025-01-27 20:44:28 -06:00
hackerESQ 94c90b8a7c fix: add menu item to create api token 2025-01-27 20:42:51 -06:00
hackerESQ f866baa37a chore: update deps 2025-01-27 20:34:16 -06:00
hackerESQ da72c17cd0 Merge pull request #56 from investbrainapp/api-wip
feat: Add Investbrain API capabilities
2025-01-27 20:32:29 -06:00
hackerESQ 1c5c4af477 Merge branch 'main' into api-wip 2025-01-27 20:29:21 -06:00
hackerESQ 83d5ad213b wip 2025-01-27 20:26:09 -06:00
hackerESQ ea22c27710 wip 2025-01-27 20:04:03 -06:00
hackerESQ 32bf256c84 chore: update deps 2025-01-27 16:47:25 -06:00
hackerESQ e498e7668e docs: clean up install guide 2025-01-27 16:41:49 -06:00
hackerESQ f58fbf9d6d docs: clean up install guide 2025-01-27 16:41:40 -06:00
hackerESQ 5e56c97bf9 docs: fix typo 2025-01-27 15:28:16 -06:00
hackerESQ 000c459d76 Merge pull request #55 from investbrainapp/optimize-dockerfile
workflow: Downgrade github runner to Ubunutu 22.04
2025-01-27 15:20:52 -06:00
hackerESQ 307f65c898 Merge branch 'main' into optimize-dockerfile 2025-01-27 15:20:46 -06:00
hackerESQ 5db54adfb7 cleanup 2025-01-27 15:16:01 -06:00
hackerESQ 19fb9a85fc cleanup 2025-01-27 14:26:40 -06:00
hackerESQ 9d48ebbad9 downgrade 2025-01-27 14:26:27 -06:00
hackerESQ 077b5257e8 swap 2025-01-27 14:02:28 -06:00
hackerESQ b84602a5ed swap 2025-01-27 13:59:03 -06:00
hackerESQ 43541c1af2 swap 2025-01-27 13:57:30 -06:00
hackerESQ 8c4d0fa1a1 merge 2025-01-27 13:23:00 -06:00
hackerESQ 16fed7a8ca add flags 2025-01-27 13:03:13 -06:00
hackerESQ c1009a19fb wip 2025-01-27 12:48:16 -06:00
hackerESQ be189cf899 typo 2025-01-27 12:01:14 -06:00
hackerESQ 8116d1d4de try sury package no buikd 2025-01-27 11:55:18 -06:00
hackerESQ ab698c8903 add fpic support for multi arch 2025-01-27 11:33:45 -06:00
hackerESQ 74b16f2165 streamline 2025-01-27 11:06:58 -06:00
hackerESQ fafbbe9b3a back to ubuntu (has docker preinstalled) 2025-01-27 10:51:31 -06:00
hackerESQ 04b32c3f33 try mac for github action 2025-01-27 10:49:24 -06:00
hackerESQ 0babcbfac4 update readme 2025-01-27 08:23:47 -06:00
hackerESQ 2da57d95b7 add pgsql back 2025-01-26 23:48:24 -06:00
hackerESQ d317c03819 Merge pull request #54 from investbrainapp/optimize-dockerfile
Optimize dockerfile
2025-01-26 23:40:16 -06:00
hackerESQ 2e187b5e08 rmeove pgsql 2025-01-26 23:39:30 -06:00
hackerESQ 064343c1ff Update build-and-push-images.yml 2025-01-26 23:25:31 -06:00
hackerESQ efc67c63d8 Update build-and-push-images.yml 2025-01-26 23:22:46 -06:00
hackerESQ a978377501 Update build-and-push-images.yml 2025-01-26 23:20:40 -06:00
hackerESQ 1bf05a1b87 use default vars for db config 2025-01-26 23:02:40 -06:00
hackerESQ 5e3c993a15 Merge pull request #53 from investbrainapp/optimize-dockerfile
Remove bcmath
2025-01-26 22:57:13 -06:00
hackerESQ 4220bb629f remove bcmath 2025-01-26 22:56:41 -06:00
hackerESQ ea4602abc7 wip 2025-01-26 22:56:05 -06:00
hackerESQ bdd30c238c Merge pull request #52 from investbrainapp/optimize-dockerfile
Optimize Docker Image
2025-01-26 21:59:56 -06:00
hackerESQ 778d799113 cleanup 2025-01-26 21:56:41 -06:00
hackerESQ 47cd1b6a91 wip 2025-01-26 21:54:56 -06:00
hackerESQ 118232e906 update readme 2025-01-26 21:50:25 -06:00
hackerESQ 64c84fe708 wip 2025-01-26 21:46:59 -06:00
hackerESQ cff3c02851 wip 2025-01-26 21:45:38 -06:00
hackerESQ 60577d02c7 wip 2025-01-26 21:45:17 -06:00
hackerESQ 99749bd9c9 this is it 2025-01-26 21:42:39 -06:00
hackerESQ b3ca2e5927 wip 2025-01-26 21:38:59 -06:00
hackerESQ b71e9e2e80 make info messages pop 2025-01-26 21:33:49 -06:00
hackerESQ 72a8aacabe wip 2025-01-26 21:30:12 -06:00
hackerESQ a0e9cfb40d wip 2025-01-26 21:24:59 -06:00
hackerESQ 46707c1149 wip 2025-01-26 21:20:02 -06:00
hackerESQ 497efcfa76 wip 2025-01-26 21:08:48 -06:00
hackerESQ 1201c248ee cleanup 2025-01-26 21:06:08 -06:00
hackerESQ 395eb31801 wip 2025-01-26 20:40:44 -06:00
hackerESQ b27edd9818 wip 2025-01-26 11:36:42 -06:00
hackerESQ 51c43e9893 make sure key is set 2025-01-26 11:31:07 -06:00
hackerESQ ec2019430e ensure storage is there 2025-01-26 11:28:20 -06:00
hackerESQ 05174e93ad wip 2025-01-26 11:20:52 -06:00
hackerESQ e8ec94bfa8 add in-line docs to env 2025-01-25 23:36:33 -06:00
hackerESQ c6642e028c updat example env 2025-01-25 23:30:00 -06:00
hackerESQ 6d5a5f46b9 update readme for new install instructions 2025-01-25 23:17:21 -06:00
hackerESQ e651eb86ca bump front end 2025-01-25 22:56:22 -06:00
hackerESQ 84171da29b bump php version 2025-01-25 22:55:45 -06:00
hackerESQ d463ec689b last one 2025-01-25 22:54:10 -06:00
hackerESQ 416a82058b getting close 2025-01-25 22:52:44 -06:00
hackerESQ 6f2324ad1b wip 2025-01-25 22:37:26 -06:00
hackerESQ c19f13edc1 wip 2025-01-25 22:36:48 -06:00
hackerESQ 390b137e0b wiiiip 2025-01-25 22:33:27 -06:00
hackerESQ 0c7d4a83f1 wip 2025-01-25 22:24:51 -06:00
hackerESQ 25112cb03a wip 2025-01-25 22:22:42 -06:00
hackerESQ 5ade4b35a0 wip 2025-01-25 22:19:54 -06:00
hackerESQ 00067c56d4 wip 2025-01-25 22:08:07 -06:00
hackerESQ 620566490b wip 2025-01-25 21:57:50 -06:00
hackerESQ 7245f4cc69 wip 2025-01-25 21:53:55 -06:00
hackerESQ 575fecb163 wip 2025-01-25 21:43:58 -06:00
hackerESQ 4120b1abfa set permission in entry script 2025-01-25 21:34:18 -06:00
hackerESQ 801d3739fc wip 2025-01-25 21:21:08 -06:00
hackerESQ 92bdf14508 wip 2025-01-25 21:17:27 -06:00
hackerESQ fa25a82693 wip 2025-01-25 21:12:16 -06:00
hackerESQ 1684f3e0cb wip 2025-01-25 21:06:54 -06:00
hackerESQ a31f807da8 wip 2025-01-25 20:49:24 -06:00
hackerESQ 6d92b49f3d wip 2025-01-25 20:46:40 -06:00
hackerESQ 11cdf975bc wip 2025-01-25 20:40:26 -06:00
hackerESQ 7bacc28e3b wip 2025-01-25 20:37:50 -06:00
hackerESQ 4bbb71d434 wip 2025-01-25 20:34:03 -06:00
hackerESQ 8da153a476 wip 2025-01-25 20:31:40 -06:00
hackerESQ 1189325638 wip 2025-01-25 20:28:38 -06:00
hackerESQ e93459ae55 wip 2025-01-25 20:25:25 -06:00
hackerESQ b1fcf51546 wip 2025-01-25 20:21:38 -06:00
hackerESQ 75716368bb wip 2025-01-25 20:20:56 -06:00
hackerESQ ec15e2bb63 wip 2025-01-25 20:15:21 -06:00
hackerESQ 9a3e030ce7 wip 2025-01-25 20:13:19 -06:00
hackerESQ 4f5894ef4a wip 2025-01-25 18:41:20 -06:00
hackerESQ e0b5610d90 wip 2025-01-25 18:35:18 -06:00
hackerESQ bc34519a26 wip 2025-01-25 18:22:12 -06:00
hackerESQ dc69bfa8c7 make php extensions required 2025-01-25 18:18:31 -06:00
hackerESQ cf7c5fc23a wip 2025-01-25 18:12:55 -06:00
hackerESQ 16d5b80657 wip 2025-01-25 18:09:02 -06:00
hackerESQ 169eabd800 fix: disable api for 1p packages 2025-01-25 16:57:35 -06:00
hackerESQ 62dcae48bb wip 2025-01-24 22:45:28 -06:00
hackerESQ b8f24d4b67 add filter-models for api controllers 2025-01-24 19:29:15 -06:00
hackerESQ 6d9e0008b8 wip 2025-01-24 19:24:16 -06:00
hackerESQ b9d41f9ac0 chore: clean up unneeded attributes 2025-01-24 19:18:27 -06:00
hackerESQ f724f450f2 fix: make default for currency values not nullable 2025-01-24 19:17:55 -06:00
hackerESQ cc447c5fb0 fix: force boolean columns to be false 2025-01-24 19:15:28 -06:00
hackerESQ b3f0f89d16 wip 2025-01-23 22:47:16 -06:00
hackerESQ 8dd153fb53 docs: added more badges 2025-01-17 12:46:29 -06:00
hackerESQ 89bfb28019 docs: add badges 2025-01-15 22:37:03 -06:00
hackerESQ 1215e47297 Merge pull request #49 from investbrainapp/upgrade-laravel
chore: upgrade composer deps
2024-12-26 12:34:53 -06:00
hackerESQ 4016899179 chore: upgrade composer deps 2024-12-26 12:34:29 -06:00
hackerESQ 1cad9b83fb Update build-and-push-images.yml 2024-12-20 23:05:05 -06:00
hackerESQ 780ee76dc3 Update build-and-push-images.yml 2024-12-20 23:02:36 -06:00
hackerESQ 4d8e17f59f Update build-and-push-images.yml 2024-12-20 22:57:02 -06:00
hackerESQ 21c27e22da Update build-and-push-images.yml 2024-12-20 22:55:56 -06:00
hackerESQ 2e978089b5 rename workflow 2024-12-20 22:25:39 -06:00
hackerESQ 803fe7147e Set up github workflow to build and push images 2024-12-20 22:24:54 -06:00
hackerESQ 6490364a5d chore: update dockerignore 2024-12-20 16:19:07 -06:00
hackerESQ 2ad773952e chore: slim down docker build 2024-12-19 21:53:50 -06:00
hackerESQ 138e71107e chore: clean up dockerfile and entrypoint script 2024-12-19 21:34:58 -06:00
hackerESQ bde399f589 chore: move dockerfile back to repo 2024-12-19 21:10:00 -06:00
hackerESQ 8a43602363 docs: clarify upsert 2024-12-18 16:03:32 -06:00
hackerESQ 5a56790fd4 docs: more refinement and word choice 2024-12-18 16:03:01 -06:00
hackerESQ 892f681174 docs: add self-hosted llm to readme intro section 2024-12-18 15:53:09 -06:00
hackerESQ 997b5420ee Merge pull request #44 from investbrainapp/create-docker-image
Use the investbrainapp/investbrain docker image
2024-12-17 18:28:03 -06:00
hackerESQ 643bbe3af2 wait for db to be ready in order to migrate 2024-12-16 21:54:59 -06:00
hackerESQ f85f0f19b9 wip 2024-12-16 21:33:45 -06:00
hackerESQ 3f9a1bafa0 Merge pull request #37 from investbrainapp/feat-use-openai-compatible-endpoints
Feat use openai compatible endpoints
2024-12-06 16:10:54 -06:00
hackerESQ 6f72a03ecf fix: specifically use factory import 2024-12-06 16:10:27 -06:00
hackerESQ 5b8e4c634e fix: remove validation which could prevent selfhosting w ollama 2024-12-06 16:09:03 -06:00
hackerESQ 70c3f7162e Merge pull request #36 from investbrainapp/feat-use-openai-compatible-endpoints
feat: adds ollama support
2024-12-06 16:02:13 -06:00
hackerESQ cb9199431a feat: adds ollama support 2024-12-06 16:01:53 -06:00
hackerESQ cba9fe1e7b feat: adds pagination to recent activity list 2024-11-29 14:22:35 -06:00
hackerESQ baa49e77eb docs: fix link to import / export section 2024-11-27 12:52:37 -06:00
hackerESQ b015462e50 docs: adds information about import / export capabilities
See #25
2024-11-27 12:51:52 -06:00
hackerESQ c9f1fc1bea feat: make the system prompt aware of current date 2024-11-14 23:44:04 -06:00
hackerESQ 1177886271 Merge pull request #32 from investbrainapp/remove-terms-checkbox-for-self-hosted
Remove terms checkbox for self hosted
2024-11-14 02:23:50 -06:00
hackerESQ 0e1c56dd18 feat: do not show terms when self hosting 2024-11-14 02:23:22 -06:00
hackerESQ eefe237dff feat: allow app debug on default install 2024-11-14 02:10:21 -06:00
hackerESQ 8d4e004177 Merge pull request #31 from investbrainapp/dev
Uses last dividend created date as start date instead of last dividend date
2024-11-14 01:30:27 -06:00
hackerESQ 1c63e2b856 fix: uses last dividend created date as start date instead of last dividend date
closes #26
2024-11-14 01:25:03 -06:00
hackerESQ 3040cbf49a chore: bump composer deps 2024-11-12 22:42:27 -06:00
hackerESQ 1a124a2571 Merge pull request #30 from investbrainapp/dev
fix: refresh dividends only once daily
2024-11-12 22:36:50 -06:00
hackerESQ 26c8c3f3b9 Merge pull request #29 from investbrainapp/dependabot/composer/laravel/framework-11.31.0
chore(deps): bump laravel/framework from 11.30.0 to 11.31.0
2024-11-12 20:56:50 -06:00
dependabot[bot] 50d814ebf6 chore(deps): bump laravel/framework from 11.30.0 to 11.31.0
Bumps [laravel/framework](https://github.com/laravel/framework) from 11.30.0 to 11.31.0.
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/11.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v11.30.0...v11.31.0)

---
updated-dependencies:
- dependency-name: laravel/framework
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-12 22:47:25 +00:00
hackerESQ 7fc20876dd fix: refresh dividends only once daily 2024-11-12 00:53:22 -06:00
hackerESQ 183108400e Merge pull request #17 from investbrainapp/dependabot/npm_and_yarn/vite-5.4.10
chore(deps-dev): bump vite from 5.3.5 to 5.4.10
2024-11-08 21:03:32 -06:00
dependabot[bot] 3055d34979 chore(deps-dev): bump vite from 5.3.5 to 5.4.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.5 to 5.4.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.10/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-09 03:01:41 +00:00
hackerESQ 747f5f5f42 Merge pull request #16 from investbrainapp/dependabot/npm_and_yarn/axios-1.7.4
chore(deps-dev): bump axios from 1.7.3 to 1.7.4
2024-11-08 21:00:30 -06:00
dependabot[bot] 4db9409b94 chore(deps-dev): bump axios from 1.7.3 to 1.7.4
Bumps [axios](https://github.com/axios/axios) from 1.7.3 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.3...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 04:10:36 +00:00
hackerESQ 8693bb29ca Update README.md 2024-11-07 22:08:46 -06:00
hackerESQ 524d8ca41d Update SECURITY.md 2024-11-07 22:08:16 -06:00
hackerESQ 3c77eca689 Create SECURITY.md 2024-11-07 22:02:53 -06:00
hackerESQ 307f74b1d9 Merge pull request #14 from investbrainapp/dev
fix: adds validation for transaction date
2024-11-07 20:46:03 -06:00
hackerESQ 0c29393f3b fix: skip dividend sync if most recent dividend was less than 24 hours ago 2024-11-07 20:40:55 -06:00
hackerESQ af3726cb91 fix: sales quantity should use rounded float numbers 2024-11-07 18:20:53 -06:00
hackerESQ 0d40fd92f0 fix: adds validation for transaction date (no post-dated transactions) 2024-11-07 18:06:27 -06:00
hackerESQ 0f55d84355 Merge pull request #13 from eltociear/patch-1
docs: update README.md
2024-11-07 17:42:31 -06:00
Ikko Eltociear Ashimine eafa889827 docs: update README.md
assstant -> assistant
2024-11-08 08:39:39 +09:00
hackerESQ 60cd880c2e docs: fix formatting for TOC 2024-11-07 17:31:50 -06:00
hackerESQ ea8de69863 docs: adds table of contents to docs 2024-11-07 17:31:17 -06:00
hackerESQ 11ef26e878 docs: add troubleshooting section 2024-11-07 17:05:25 -06:00
hackerESQ 2770ebf958 Merge pull request #12 from investbrainapp/dev
fix:ensure portfolio is available on transactions page
2024-11-07 16:23:03 -06:00
hackerESQ 536ca56c24 fix:ensure portfolio is available on transactions page 2024-11-07 16:18:29 -06:00
hackerESQ d4407d3492 Merge pull request #10 from investbrainapp/dev
Make ASSET_URL optional in env
2024-11-07 12:20:08 -06:00
hackerESQ 81766b4aba fix:don't force set asset_url
related to discussion #7
2024-11-07 12:16:49 -06:00
hackerESQ e1cc040984 Merge pull request #6 from investbrainapp/dev
chore: minor updates for landing page
2024-11-06 23:07:40 -06:00
hackerESQ cdda9d7ff7 chore:maintain lock file 2024-11-06 23:02:31 -06:00
hackerESQ 9b6afe180d fix:logic for selfhosted landing page 2024-11-06 23:01:56 -06:00
330 changed files with 27933 additions and 76803 deletions
+16
View File
@@ -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/*
+26 -26
View File
@@ -1,23 +1,38 @@
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_PORT=8000 APP_PORT=8000
# Used internally to generate absolute links
APP_URL="http://localhost:${APP_PORT}" APP_URL="http://localhost:${APP_PORT}"
# Webroot for static assets (css, js, images, etc)
ASSET_URL="${APP_URL}" ASSET_URL="${APP_URL}"
SELF_HOSTED=true
# Enables or disables new user registration
REGISTRATION_ENABLED=true REGISTRATION_ENABLED=true
# Enable or disable AI chat feature
AI_CHAT_ENABLED=false AI_CHAT_ENABLED=false
# API key for OpenAI (for Llama support, see docs)
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_ORGANIZATION= OPENAI_ORGANIZATION=
# Market data provider to use (comma separated list)
MARKET_DATA_PROVIDER=yahoo MARKET_DATA_PROVIDER=yahoo
MARKET_DATA_REFRESH=30
ALPHAVANTAGE_API_KEY= ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY= FINNHUB_API_KEY=
ALPACA_API_KEY=
ALPACA_API_SECRET=
TWELVEDATA_API_SECRET=
# Cadence to refresh market data (in minutes)
MARKET_DATA_REFRESH=30
DAILY_CHANGE_TIME=
#### Advanced configurations ####
ENABLED_LOGIN_PROVIDERS= ENABLED_LOGIN_PROVIDERS=
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
@@ -28,10 +43,10 @@ LINKEDIN_CLIENT_SECRET=
FACEBOOK_CLIENT_ID= FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET= FACEBOOK_CLIENT_SECRET=
APP_LOCALE=en FILESYSTEM_DISK=local
APP_FALLBACK_LOCALE=en SESSION_DRIVER=redis
APP_FAKER_LOCALE=en_US QUEUE_CONNECTION=redis
CACHE_STORE=redis
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=investbrain-mysql DB_HOST=investbrain-mysql
@@ -40,20 +55,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
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
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
@@ -71,5 +73,3 @@ 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,66 @@
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: |
VERSION="${GITHUB_REF_NAME#v}"
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
# Conditionally add 'latest' tags unless 'pre-release' is in the version
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile
push: true
tags: ${{ steps.extract-version.outputs.tags }}
build-args: |
VERSION=${{ github.ref_name }}
+107 -28
View File
@@ -1,38 +1,58 @@
<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>
[![GitHub Repo Stars](https://img.shields.io/github/stars/investbrainapp/investbrain?style=for-the-badge&color=%23CCCCCC)](https://github.com/investbrainapp/investbrain/)
[![GitHub Contributors](https://img.shields.io/github/contributors/investbrainapp/investbrain?style=for-the-badge)](https://github.com/investbrainapp/investbrain/)
[![GitHub Issues](https://img.shields.io/github/issues/investbrainapp/investbrain?style=for-the-badge)](https://github.com/investbrainapp/investbrain/issues)
[![Docker Pulls](https://img.shields.io/docker/pulls/investbrainapp/investbrain?style=for-the-badge)](https://hub.docker.com/r/investbrainapp/investbrain/)
## About Investbrain ## About Investbrain
Investbrain is a smart open-source investment tracker that helps you manage, track, and make informed decisions about 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 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 an integration with OpenAI's LLMs for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode. Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! 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.
## Self hosting ## 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`. **3. Run `docker compose up`**
If everything worked as expected, you should now be able to access Investbrain in the browser at. You should create an account by visiting: It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
```bash ```bash
http://localhost:8000/register http://localhost:8000/register
@@ -44,17 +64,19 @@ Congrats! You've just installed Investbrain!
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. 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 assstant 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). 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).
Always keep in mind the limitations of large language models. When in doubt, consult a licensed investment advisor. 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](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration ### Configuration
You can specify the market data 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
@@ -62,13 +84,13 @@ MARKET_DATA_PROVIDER=yahoo
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. 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
``` ```
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage. In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
### Custom providers ### Custom providers
@@ -93,28 +115,45 @@ 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](#self-hosting). 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 |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo | | APP_KEY | Encryption key for various security-related functions | Set automatically during install |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
| 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` |
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 | | MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC | | APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` | | AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` | | OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` |
| OPENAI_ORGANIZATION | OpenAI org id (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 | | DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` | | 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 and are cached during run-time. If change any environment configurations, you'll have to restart the container before your changes take effect.
> 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.
## Updating ## Updating
@@ -124,10 +163,10 @@ To update Investbrain using the recommended [Docker installation](#self-hosting)
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
``` ```
Finally bring the containers back up! Finally bring the containers back up!
@@ -140,7 +179,7 @@ Easy as that!
## Command line utilities ## Command line utilities
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
To run these commands, you can use `docker exec` like this: To run these commands, you can use `docker exec` like this:
@@ -148,16 +187,56 @@ To run these commands, you can use `docker exec` like this:
docker exec -it investbrain-app php artisan <replace with command you want to run> docker exec -it investbrain-app php artisan <replace with command you want to run>
``` ```
Just to be safe, we recommend backing up your portfolios before using these commands: If you need more details on what the command does, you can take a look at the options available using the `help` option:
```bash
<command you want to run> --help
```
| Command | Description | | Command | Description |
| ------------- | ------------- | | ------------- | ------------- |
| refresh:market-data | Refreshes market data with your configured market data provider. | | refresh:market-data | Refreshes market data with your configured market data provider. |
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. | | refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. | | refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. | | capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) | | sync:daily-change | Syncs 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 | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
## 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
@@ -185,7 +264,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
+14
View File
@@ -0,0 +1,14 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.2.x | :white_check_mark: |
| 1.1.x | :x: |
| 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.
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Currency;
use Illuminate\Database\Eloquent\Model;
class ConvertToMarketDataCurrency
{
public function __invoke(Model $model, callable $next)
{
if (is_null($model?->market_data)) {
$model->loadMarketData();
}
if (! is_null($model->currency) && $model->currency !== $model->market_data->currency) {
// convert to market data currency
$model->cost_basis = Currency::convert(
value: $model->cost_basis,
from: $model->currency,
to: $model->market_data->currency,
date: $model->date
);
if ($model->transaction_type == 'SELL') {
$model->sale_price = Currency::convert(
value: $model->sale_price,
from: $model->currency,
to: $model->market_data->currency,
date: $model->date
);
}
}
// currency cannot be saved to the database - we already know market_data.currency anyway
unset($model->currency);
return $next($model);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Casts\BaseCurrency;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class CopyToBaseCurrency
{
public function __invoke(Model $model, callable $next)
{
foreach ($model->getCasts() as $key => $value) {
if ($value === BaseCurrency::class) {
$model[$key] = $model[Str::beforeLast($key, '_base')];
}
}
return $next($model);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Model;
class EnsureCostBasisAddedToSale
{
public function __invoke(Model $model, callable $next)
{
// cost basis is required for sales to calculate realized gains
if ($model->transaction_type == 'SELL') {
$cost_basis = Transaction::where([
'portfolio_id' => $model->portfolio_id,
'symbol' => $model->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $model->date)
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
->selectRaw('SUM(transactions.quantity) as total_quantity')
->first();
$average_cost_basis = empty($cost_basis->total_quantity)
? 0
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
$model->cost_basis = $average_cost_basis ?? 0;
}
return $next($model);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use function Illuminate\Support\defer;
class EnsureDailyChangeIsSynced
{
public function __invoke(Model $model, callable $next)
{
if (config('app.env') != 'testing') {
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
if (
! Cache::has($cacheKey)
&& $model->date->lessThan(now())
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
)
) {
defer(fn () => $model->portfolio->syncDailyChanges());
Cache::put($cacheKey, now(), now()->addMinutes(5));
}
}
return $next($model);
}
}
+13 -3
View File
@@ -1,10 +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 App\Traits\WithTrimStrings;
use Laravel\Jetstream\Jetstream;
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;
@@ -30,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', 'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
])->validate(); ])->validate();
return User::create([ $user = User::make([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
]); ]);
// ensure first user is flagged as an admin
if (User::count() === 0) {
$user->admin = true;
}
$user->save();
return $user;
} }
} }
@@ -1,5 +1,7 @@
<?php <?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,18 +1,20 @@
<?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 App\Traits\WithTrimStrings;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{ {
use WithTrimStrings; use WithTrimStrings;
/** /**
* Validate and update the given user's profile information. * Validate and update the given user's profile information.
* *
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Actions\Jetstream;
use App\Models\User;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*/
public function delete(User $user): void
{
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use App\Models\Currency;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class BaseCurrency implements CastsAttributes
{
/**
* Cast the given value to user's display currency
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
return (float) $value;
}
/**
* Prepare the given value for storage in base currency
*
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
// for market data and transactions the `currency` attribute is available...
// but for dividends and other types, need to make sure `market_data` is loaded
if (is_null($model?->currency)) {
$model->loadMarketData();
}
return Currency::convert(
(float) $value,
$model?->currency ?? $model->market_data?->currency,
config('investbrain.base_currency'),
$model?->date
);
}
}
+9 -16
View File
@@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Holding;
use App\Models\Portfolio; use App\Models\Portfolio;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -38,27 +41,17 @@ 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'); $metrics = Holding::query()
->portfolio($portfolio->id)
$total_dividends = $portfolio->holdings->sum('dividends_earned'); ->getPortfolioMetrics(config('investbrain.base_currency'));
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
$total_market_value = $portfolio->holdings->sum(function($holding) {
return $holding->market_data->market_value * $holding->quantity;
});
$portfolio->daily_change()->create([ $portfolio->daily_change()->create([
'date' => now(), 'date' => now(),
'total_market_value' => $total_market_value, 'total_market_value' => $metrics->get('total_market_value'),
'total_cost_basis' => $total_cost_basis,
'total_gain' => $total_market_value - $total_cost_basis,
'total_dividends_earned' => $total_dividends,
'realized_gains' => $realized_gains
]); ]);
}); });
} }
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Console\Command;
class FixCostBasisForSales extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:cost-basis-for-sales
{--portfolio= : The ID of the portfolio to fix.}
{--user= : The user ID of transactions to fix.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fixes broken costs basis for sale transactions';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
$this->error('Must provide at least a user or portfolio.');
return;
}
$transactions = Transaction::where(['transaction_type' => 'SELL']);
if ($this->option('user')) {
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
->pluck('id')
->toArray();
$transactions->whereIn('portfolio_id', $portfolios);
} else {
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
}
$transactions = $transactions->get();
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
$transactions->chunk(10)->each(function ($chunk) {
dispatch(function () use ($chunk) {
$chunk->each(function ($transaction) {
$cost_basis = Transaction::where([
'portfolio_id' => $transaction->portfolio_id,
'symbol' => $transaction->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $transaction->date)
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
->selectRaw('SUM(transactions.quantity) as total_quantity')
->first();
$average_cost_basis = empty($cost_basis->total_quantity)
? 0
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
$transaction->cost_basis = $average_cost_basis ?? 0;
$transaction->save();
});
});
});
$this->line('Done!');
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\CurrencyRate;
use Illuminate\Console\Command;
class RefreshCurrencyData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'refresh:currency-data
{--force : Refresh of currency data}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh currency data from data provider';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
}
}
+7 -5
View File
@@ -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
@@ -43,17 +45,17 @@ class RefreshDividendData extends Command
{ {
$holdings = Holding::distinct(); $holdings = Holding::distinct();
if (!($this->option('force') ?? false)) { if (! ($this->option('force') ?? false)) {
$holdings->where('quantity', '>', 0); $holdings->where('quantity', '>', 0);
} }
if ($this->option('user')) { if ($this->option('user')) {
$holdings->myHoldings($this->option('user')); $holdings->myHoldings($this->option('user'));
} }
foreach ($holdings->get(['symbol']) as $holding) { foreach ($holdings->get(['symbol']) as $holding) {
$this->line('Refreshing ' . $holding->symbol); $this->line('Refreshing '.$holding->symbol);
Dividend::refreshDividendData($holding->symbol); Dividend::refreshDividendData($holding->symbol);
} }
} }
+12 -6
View File
@@ -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;
@@ -42,20 +44,24 @@ class RefreshMarketData extends Command
public function handle() public function handle()
{ {
$force = $this->option('force') ?? false; $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();
if ($this->option('user')) { if ($this->option('user')) {
$holdings->myHoldings($this->option('user')); $holdings->myHoldings($this->option('user'));
} }
foreach ($holdings->get() as $holding) { foreach ($holdings->get() as $holding) {
$this->line('Refreshing ' . $holding->symbol); $this->line('Refreshing '.$holding->symbol);
MarketData::getMarketData($holding->symbol, $force); try {
MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) {
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
}
} }
} }
} }
+8 -6
View File
@@ -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
@@ -42,14 +44,14 @@ class RefreshSplitData extends Command
{ {
$holdings = Holding::distinct(); $holdings = Holding::distinct();
if (!($this->option('force') ?? false)) { if (! ($this->option('force') ?? false)) {
$holdings->where('quantity', '>', 0); $holdings->where('quantity', '>', 0);
} }
foreach ($holdings->get(['symbol']) as $holding) { foreach ($holdings->get(['symbol']) as $holding) {
$this->line('Refreshing ' . $holding->symbol); $this->line('Refreshing '.$holding->symbol);
Split::refreshSplitData($holding->symbol); Split::refreshSplitData($holding->symbol);
} }
} }
} }
+5 -2
View File
@@ -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) {
+3 -1
View File
@@ -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;
@@ -47,7 +49,7 @@ class SyncHoldingData extends Command
} }
foreach ($holdings->get() as $holding) { foreach ($holdings->get() as $holding) {
$this->line('Refreshing ' . $holding->symbol); $this->line('Refreshing '.$holding->symbol);
$holding->syncTransactionsAndDividends(); $holding->syncTransactionsAndDividends();
} }
+10 -10
View File
@@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace App\Exports; namespace App\Exports;
use App\Exports\Sheets\ConfigSheet;
use App\Exports\Sheets\DailyChangesSheet; use App\Exports\Sheets\DailyChangesSheet;
use App\Exports\Sheets\PortfoliosSheet; use App\Exports\Sheets\PortfoliosSheet;
use App\Exports\Sheets\TransactionsSheet; use App\Exports\Sheets\TransactionsSheet;
@@ -14,18 +17,15 @@ 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),
]; new ConfigSheet($this->empty),
];
} }
} }
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Exports\Sheets;
use App\Models\Holding;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithTitle;
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
{
public function __construct(
public bool $empty = false
) {}
public function headings(): array
{
return [
'Key',
'Value',
];
}
/**
* @return \Illuminate\Support\Collection
*/
public function collection()
{
$configs = collect();
if ($this->empty) {
return $configs;
}
// collect user settings
$configs->push([
'key' => 'name',
'value' => auth()->user()->name,
], [
'key' => 'locale',
'value' => auth()->user()->getLocale(),
], [
'key' => 'display_currency',
'value' => auth()->user()->getCurrency(),
]);
// reinvested holdings
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
if ($reinvested_holdings->isNotEmpty()) {
$configs->push([
'key' => 'reinvested_dividends',
'value' => $reinvested_holdings->toJson(),
]);
}
return $configs;
}
public function title(): string
{
return 'Config';
}
}
+25 -10
View File
@@ -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
{ {
@@ -20,24 +22,37 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
'Portfolio ID', 'Portfolio ID',
'Total Market Value', 'Total Market Value',
'Total Cost Basis', 'Total Cost Basis',
'Total Gain',
'Total Dividends Earned',
'Realized Gains', 'Realized Gains',
'Annotation' 'Total Dividends Earned',
'Annotation',
]; ];
} }
/** /**
* @return \Illuminate\Support\Collection * @return \Illuminate\Support\Collection
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : DailyChange::myDailyChanges()->get(); if ($this->empty) {
return collect();
}
return DailyChange::myDailyChanges()
->withDailyPerformance()
->get()
->map(function ($daily_change) {
return [
'date' => date_format($daily_change->date, 'Y-m-d'),
'portfolio_id' => $daily_change->portfolio_id,
'total_market_value' => $daily_change->total_market_value,
'total_cost_basis' => $daily_change->total_cost_basis,
'realized_gains' => $daily_change->realized_gain_dollars,
'total_dividends_earned' => $daily_change->total_dividends_earned,
'annotation' => $daily_change->annotation,
];
});
} }
/**
* @return string
*/
public function title(): string public function title(): string
{ {
return 'Daily Changes'; return 'Daily Changes';
+7 -8
View File
@@ -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';
+33 -10
View File
@@ -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
{ {
@@ -23,25 +25,46 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'Quantity', 'Quantity',
'Cost Basis', 'Cost Basis',
'Sale Price', 'Sale Price',
'Currency',
'Split', 'Split',
'Reinvested Dividend', '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(); if ($this->empty) {
return collect();
}
return Transaction::myTransactions()
->withMarketData()
->get()
->map(function ($transaction) {
return [
'id' => $transaction->id,
'symbol' => $transaction->symbol,
'portfolio_id' => $transaction->portfolio_id,
'transaction_type' => $transaction->transaction_type,
'quantity' => $transaction->quantity,
'cost_basis' => $transaction->cost_basis,
'sale_price' => $transaction->sale_price,
'currency' => $transaction->market_data_currency,
'split' => $transaction->split,
'reinvested_dividend' => $transaction->reinvested_dividend,
'date' => date_format($transaction->date, 'Y-m-d'),
'created_at' => $transaction->created_at,
'updated_at' => $transaction->updated_at,
];
});
} }
/**
* @return string
*/
public function title(): string public function title(): string
{ {
return 'Transactions'; return 'Transactions';
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
abstract class Controller
{
//
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Requests\HoldingRequest;
use App\Http\Resources\HoldingResource;
use App\Models\Holding;
use App\Models\Portfolio;
use HackerEsq\FilterModels\FilterModels;
use Illuminate\Support\Facades\Gate;
class HoldingController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Holding::query());
$filters->setScopes(['myHoldings']);
$filters->setEagerRelations(['market_data', 'transactions']);
$filters->setSearchableColumns(['symbol']);
return HoldingResource::collection($filters->paginated());
}
public function show(Portfolio $portfolio, string $symbol)
{
Gate::authorize('readOnly', $portfolio);
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
return HoldingResource::make($holding);
}
public function update(HoldingRequest $request, Portfolio $portfolio, string $symbol)
{
Gate::authorize('fullAccess', $portfolio);
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
$holding->update($request->validated());
return HoldingResource::make($holding);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Resources\MarketDataResource;
use App\Models\MarketData;
use Illuminate\Http\Request;
class MarketDataController extends ApiController
{
public function show(Request $request, string $symbol)
{
try {
return MarketDataResource::make(
MarketData::getMarketData($symbol)
);
} catch (\Throwable $e) {
return response([
'message' => 'Symbol '.$symbol.' not found.',
], 404);
}
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Requests\PortfolioRequest;
use App\Http\Resources\PortfolioResource;
use App\Models\Portfolio;
use HackerEsq\FilterModels\FilterModels;
use Illuminate\Support\Facades\Gate;
class PortfolioController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Portfolio::query());
$filters->setScopes(['myPortfolios']);
$filters->setEagerRelations(['users', 'transactions', 'holdings']);
$filters->setFilterableRelations(['holdings.symbol']);
$filters->setSearchableColumns(['title', 'notes']);
return PortfolioResource::collection($filters->paginated());
}
public function store(PortfolioRequest $request)
{
$portfolio = Portfolio::create($request->validated());
return PortfolioResource::make($portfolio);
}
public function show(Portfolio $portfolio)
{
Gate::authorize('readOnly', $portfolio);
return PortfolioResource::make($portfolio);
}
public function update(PortfolioRequest $request, Portfolio $portfolio)
{
Gate::authorize('fullAccess', $portfolio);
$portfolio->update($request->validated());
return PortfolioResource::make($portfolio);
}
public function destroy(Portfolio $portfolio)
{
Gate::authorize('fullAccess', $portfolio);
$portfolio->delete();
return response()->noContent();
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Requests\TransactionRequest;
use App\Http\Resources\TransactionResource;
use App\Models\Transaction;
use HackerEsq\FilterModels\FilterModels;
use Illuminate\Support\Facades\Gate;
class TransactionController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Transaction::query());
$filters->setScopes(['myTransactions']);
$filters->setEagerRelations(['market_data']);
$filters->setSearchableColumns(['symbol']);
return TransactionResource::collection($filters->paginated());
}
public function store(TransactionRequest $request)
{
Gate::authorize('fullAccess', $request->portfolio);
$transaction = Transaction::create($request->validated());
return TransactionResource::make($transaction);
}
public function show(Transaction $transaction)
{
Gate::authorize('readOnly', $transaction->portfolio);
return TransactionResource::make($transaction);
}
public function update(TransactionRequest $request, Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->update($request->validated());
return TransactionResource::make($transaction);
}
public function destroy(Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->delete();
return response()->noContent();
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
class UserController extends ApiController
{
public function me(Request $request)
{
return UserResource::make($request->user());
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ApiTokenController extends Controller
{
/**
* Show the user API token screen.
*
* @return \Illuminate\View\View
*/
public function index(Request $request)
{
return view('api.index', [
'request' => $request,
'user' => $request->user(),
]);
}
}
@@ -1,23 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Exception;
use App\Models\User;
use App\Models\ConnectedAccount; use App\Models\ConnectedAccount;
use Illuminate\Support\MessageBag; use App\Models\User;
use App\Http\Controllers\Controller; use App\Notifications\VerifyConnectedAccountNotification;
use Exception;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\MessageBag;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use App\Notifications\VerifyConnectedAccountNotification;
class ConnectedAccountController extends Controller class ConnectedAccountController extends Controller
{ {
/** /**
* Redirect the user to the GitHub authentication page. * Redirect the user to the GitHub authentication page.
*
*/ */
public function redirectToProvider(string $provider) public function redirectToProvider(string $provider)
{ {
@@ -28,7 +27,6 @@ class ConnectedAccountController extends Controller
/** /**
* Obtain the user information from GitHub. * Obtain the user information from GitHub.
*
*/ */
public function handleProviderCallback(string $provider) public function handleProviderCallback(string $provider)
{ {
@@ -45,21 +43,21 @@ class ConnectedAccountController extends Controller
} }
// check if this account is already linked // check if this account is already linked
$connected_account = ConnectedAccount::firstOrNew([ $connected_account = ConnectedAccount::firstOrNew([
'provider' => $provider, 'provider' => $provider,
'provider_id' => $providerUser->id 'provider_id' => $providerUser->id,
], [ ], [
'token' => $providerUser->token, 'token' => $providerUser->token,
'secret' => $providerUser->tokenSecret, 'secret' => $providerUser->tokenSecret,
'refresh_token' => $providerUser->refreshToken, 'refresh_token' => $providerUser->refreshToken,
'expires_at' => $providerUser->expiresIn, 'expires_at' => $providerUser->expiresIn,
'verified_at' => false 'verified_at' => false,
]); ]);
// already linked and verified, let's go login! // already linked and verified, let's go login!
if ( if (
$connected_account->exists $connected_account->exists
&& !is_null($connected_account->verified_at) && ! is_null($connected_account->verified_at)
) { ) {
Auth::login($connected_account->user, true); Auth::login($connected_account->user, true);
@@ -68,20 +66,20 @@ class ConnectedAccountController extends Controller
} }
// new user, let's create one // new user, let's create one
if (!$user = User::where('email', $providerUser->email)->first()) { if (! $user = User::where('email', $providerUser->email)->first()) {
$user = User::create([ $user = User::create([
'name' => $providerUser->name, 'name' => $providerUser->name,
'email' => $providerUser->email, 'email' => $providerUser->email,
'email_verified_at' => now() 'email_verified_at' => now(),
]); ]);
$connected_account->user_id = $user->id; $connected_account->user_id = $user->id;
$connected_account->verified_at = now(); $connected_account->verified_at = now();
$connected_account->save(); $connected_account->save();
Auth::login($user, true); Auth::login($user, true);
return redirect(route('dashboard')); return redirect(route('dashboard'));
} }
@@ -92,23 +90,23 @@ class ConnectedAccountController extends Controller
$user->notify(new VerifyConnectedAccountNotification($connected_account->id)); $user->notify(new VerifyConnectedAccountNotification($connected_account->id));
return redirect(route('login')) return redirect(route('login'))
->with('status', __( ->with('status', __(
'Account already exists. Check your email to connect your :provider account.', 'Account already exists. Check your email to connect your :provider account.',
['provider' => config("services.$provider.name")] ['provider' => config("services.$provider.name")]
)); ));
} }
protected function validateProvider($provider): void protected function validateProvider($provider): void
{ {
if (!in_array($provider, explode(',', config('services.enabled_login_providers')))) { if (! in_array($provider, explode(',', config('services.enabled_login_providers')))) {
throw new Exception('Please provide a valid social provider.'); throw new Exception('Please provide a valid social provider.');
} }
} }
public function verify(ConnectedAccount $connected_account) public function verify(ConnectedAccount $connected_account)
{ {
if (!$connected_account->verified_at) { if (! $connected_account->verified_at) {
// mark request as verified // mark request as verified
$connected_account->verified_at = now(); $connected_account->verified_at = now();
@@ -126,10 +124,10 @@ class ConnectedAccountController extends Controller
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]), 'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
'description' => null, 'description' => null,
'css' => 'alert-success', 'css' => 'alert-success',
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"), 'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='o-check-circle' />"),
'position' => 'toast-top toast-end', 'position' => 'toast-top toast-end',
'timeout' => '5000' 'timeout' => '5000',
] ],
])); ]));
} }
} }
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
abstract class Controller abstract class Controller
+7 -7
View File
@@ -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,16 +17,14 @@ 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()->remember( $metrics = cache()->tags(['metrics-'.$user->id])->remember(
'dashboard-metrics-' . $user->id, 'dashboard-metrics-'.$user->id,
10, 10,
function () { function () {
return return Holding::query()
Holding::query()
->myHoldings() ->myHoldings()
->withoutWishlists() ->withoutWishlists()
->withPortfolioMetrics() ->getPortfolioMetrics();
->first();
} }
); );
+11 -10
View File
@@ -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,21 +10,20 @@ 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();
$formattedTransactions = $holding->getFormattedTransactions(); $formattedTransactions = $holding->getFormattedTransactions();
@@ -1,23 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Portfolio; use App\Models\Portfolio;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class InvitedOnboardingController extends Controller class InvitedOnboardingController extends Controller
{ {
/** /**
* Check if the invited user needs a password? * Check if the invited user needs a password?
*
*/ */
public function __invoke(Request $request, Portfolio $portfolio, User $user) public function __invoke(Request $request, Portfolio $portfolio, User $user)
{ {
if (!$request->hasValidSignature()) { if (! $request->hasValidSignature()) {
abort(401, 'Invalid signature'); abort(401, 'Invalid signature');
} }
@@ -27,7 +26,7 @@ class InvitedOnboardingController extends Controller
// route to create password form // route to create password form
return view('auth.invited-onboarding', [ return view('auth.invited-onboarding', [
'portfolio' => $portfolio, 'portfolio' => $portfolio,
'user' => $user 'user' => $user,
]); ]);
} }
+11 -12
View File
@@ -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 Illuminate\Http\Request; 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.
*/ */
@@ -22,26 +24,23 @@ class PortfolioController extends Controller
*/ */
public function show(Request $request, Portfolio $portfolio) public function show(Request $request, Portfolio $portfolio)
{ {
if ($request->user()->cannot('readOnly', $portfolio)) { Gate::authorize('readOnly', $portfolio);
abort(403);
}
$portfolio->load(['transactions', 'holdings']); $portfolio->load(['transactions', 'holdings']);
// get portfolio metrics // get portfolio metrics
$metrics = cache()->remember( $metrics = cache()->tags(['metrics-'.$request->user()->id])->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() ->getPortfolioMetrics();
->first();
} }
); );
$formattedHoldings = $portfolio->getFormattedHoldings(); $formattedHoldings = $portfolio->getFormattedHoldings();
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings'])); return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
} }
} }
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class PrivacyPolicyController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the privacy policy for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$policyFile = $this->localizedMarkdownPath('policy.md');
return view('policy', [
'policy' => Str::markdown(file_get_contents($policyFile)),
]);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\HasLocalizedMarkdown;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
class TermsOfServiceController extends Controller
{
use HasLocalizedMarkdown;
/**
* Show the terms of service for the application.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
$termsFile = $this->localizedMarkdownPath('terms.md');
return view('terms', [
'terms' => Str::markdown(file_get_contents($termsFile)),
]);
}
}
@@ -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.
*/ */
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UserProfileController extends Controller
{
/**
* Show the user profile screen.
*
* @return \Illuminate\View\View
*/
public function show(Request $request)
{
return view('profile.show', [
'request' => $request,
'user' => $request->user(),
]);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Number;
use Illuminate\Support\Str;
class LocalizationMiddleware
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (Auth::check()) {
$locale = auth()->user()->getLocale();
app()->setLocale(Str::before($locale, '_'));
Number::useLocale($locale);
Number::useCurrency(auth()->user()->getCurrency());
}
return $next($request);
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetLocale
{
/**
* Handle an incoming request.
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (!session()->has('locale')) {
session()->put('locale', $request->getPreferredLanguage(
config('app.available_locales')
));
}
app()->setLocale(session('locale'));
return $next($request);
}
}
+15
View File
@@ -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};
}
}
+23
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Portfolio;
use App\Rules\QuantityValidationRule;
use App\Rules\SymbolValidationRule;
class TransactionRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'portfolio' => Portfolio::find($this->requestOrModelValue('portfolio_id', 'transaction')),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'portfolio_id' => ['required', 'exists:portfolios,id'],
'symbol' => ['required', 'string', new SymbolValidationRule],
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
'quantity' => [
'required',
'numeric',
'gt:0',
new QuantityValidationRule(
$this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'),
$this->requestOrModelValue('transaction_type', 'transaction'),
$this->requestOrModelValue('date', 'transaction'),
$this->transaction
),
],
'currency' => ['required', 'exists:currencies,currency'],
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
];
if (! is_null($this->transaction)) {
$rules['portfolio_id'][0] = 'sometimes';
$rules['symbol'][0] = 'sometimes';
$rules['transaction_type'][0] = 'sometimes';
$rules['currency'][0] = 'sometimes';
$rules['date'][0] = 'sometimes';
$rules['quantity'][0] = 'sometimes';
if (
$this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
&& $this->requestOrModelValue('sale_price', 'transaction') == null
) {
$rules['sale_price'][0] = 'required';
} elseif (
$this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
&& $this->requestOrModelValue('cost_basis', 'transaction') == null
) {
$rules['cost_basis'][0] = 'required';
}
}
return $rules;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class HoldingResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'reinvest_dividends' => $this->reinvest_dividends,
'average_cost_basis' => $this->average_cost_basis,
'total_cost_basis' => $this->total_cost_basis,
'realized_gain_dollars' => $this->realized_gain_dollars,
'dividends_earned' => $this->dividends_earned,
'splits_synced_at' => $this->splits_synced_at,
'total_market_value' => $this->total_market_value,
'market_gain_dollars' => $this->market_gain_dollars,
'market_gain_percent' => $this->market_gain_percent,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+36
View File
@@ -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,
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PortfolioResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'notes' => $this->notes,
'wishlist' => $this->wishlist,
'owner' => UserResource::make($this->owner),
'transactions' => TransactionResource::collection($this->whenLoaded('transactions')),
'holdings' => HoldingResource::collection($this->whenLoaded('holdings')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TransactionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'symbol' => $this->symbol,
'portfolio_id' => $this->portfolio_id,
'transaction_type' => $this->transaction_type,
'quantity' => $this->quantity,
'currency' => $this->market_data->currency,
'cost_basis' => $this->cost_basis,
'sale_price' => $this->sale_price,
'split' => $this->split,
'reinvested_dividend' => $this->reinvested_dividend,
'date' => date_format($this->date, 'Y-m-d'),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'profile_photo_url' => $this->profile_photo_url,
'options' => [
'display_currency' => $this->getCurrency(),
'locale' => $this->getLocale(),
],
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+24 -24
View File
@@ -1,40 +1,39 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports; namespace App\Imports;
use App\Models\User; use App\Console\Commands\RefreshDividendData;
use App\Imports\Sheets\PortfoliosSheet; use App\Console\Commands\RefreshMarketData;
use Illuminate\Support\Facades\Artisan;
use App\Console\Commands\SyncDailyChange; use App\Console\Commands\SyncDailyChange;
use App\Console\Commands\SyncHoldingData; use App\Console\Commands\SyncHoldingData;
use App\Imports\Sheets\ConfigSheet;
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 Maatwebsite\Excel\Events\AfterImport; use App\Models\BackupImport as BackupImportModel;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Maatwebsite\Excel\Concerns\Importable; use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithEvents; use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Events\AfterImport;
use Maatwebsite\Excel\Events\BeforeImport; use Maatwebsite\Excel\Events\BeforeImport;
use Maatwebsite\Excel\Events\ImportFailed; use Maatwebsite\Excel\Events\ImportFailed;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\RefreshDividendData;
use App\Models\BackupImport as BackupImportModel;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class BackupImport implements WithMultipleSheets, WithEvents class BackupImport implements WithEvents, WithMultipleSheets
{ {
use Importable; use Importable;
public function __construct( public function __construct(
public BackupImportModel $backupImportModel public BackupImportModel $backupImportModel
) { } ) {}
/**
* @return array
*/
public function registerEvents(): array public function registerEvents(): array
{ {
return [ return [
BeforeImport::class => fn() => $this->backupImportModel->update([ BeforeImport::class => fn () => $this->backupImportModel->update([
'status' => 'in_progress', 'status' => 'in_progress',
'message' => __('Import is in progress...'), 'message' => __('Import is in progress...'),
]), ]),
@@ -43,24 +42,24 @@ class BackupImport implements WithMultipleSheets, WithEvents
$this->backupImportModel->update([ $this->backupImportModel->update([
'status' => 'success', 'status' => 'success',
'message' => 'Import completed successfully!', 'message' => 'Import completed successfully!',
'completed_at' => now() 'completed_at' => now(),
]); ]);
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]) Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
->chain([ ->chain([
fn() => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]), fn () => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
fn() => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]), fn () => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
fn() => User::find($this->backupImportModel->user_id)->portfolios->each(function($portfolio) { fn () => User::find($this->backupImportModel->user_id)->portfolios->each(function ($portfolio) {
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]); Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
}) }),
]); ]);
}, },
ImportFailed::class => fn(ImportFailed $event) => $this->backupImportModel->update([ ImportFailed::class => fn (ImportFailed $event) => $this->backupImportModel->update([
'status' => 'failed', 'status' => 'failed',
'message' => 'Error: '. substr($event->getException()->getMessage(), 0, 220), 'message' => 'Error: '.substr($event->getException()->getMessage(), 0, 220),
'has_errors' => true, 'has_errors' => true,
'completed_at' => now() 'completed_at' => now(),
]), ]),
]; ];
} }
@@ -71,6 +70,7 @@ class BackupImport implements WithMultipleSheets, WithEvents
'Portfolios' => new PortfoliosSheet($this->backupImportModel), 'Portfolios' => new PortfoliosSheet($this->backupImportModel),
'Transactions' => new TransactionsSheet($this->backupImportModel), 'Transactions' => new TransactionsSheet($this->backupImportModel),
'Daily Changes' => new DailyChangesSheet($this->backupImportModel), 'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
'Config' => new ConfigSheet($this->backupImportModel),
]; ];
} }
} }
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Imports\Sheets;
use App\Models\BackupImport;
use App\Models\Holding;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Events\BeforeSheet;
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{
public function __construct(
public BackupImport $backupImport
) {}
public function registerEvents(): array
{
return [
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing configurations...'),
]);
DB::beginTransaction();
},
];
}
public function collection(Collection $configs)
{
foreach ($configs as $config) {
switch ($config['key']) {
case 'name':
$this->backupImport->user->setAttribute('name', $config['value']);
$this->backupImport->user->save();
break;
case 'locale':
$this->backupImport->user->setOption('locale', $config['value']);
$this->backupImport->user->save();
break;
case 'display_currency':
$this->backupImport->user->setOption('display_currency', $config['value']);
$this->backupImport->user->save();
break;
case 'reinvested_dividends':
if (json_validate($config['value'])) {
foreach (json_decode($config['value'], true) as $reinvest) {
Holding::myHoldings($this->backupImport->user->id)
->where('portfolio_id', $reinvest['portfolio_id'])
->where('symbol', $reinvest['symbol'])
->update([
'reinvest_dividends' => true,
]);
}
}
break;
default:
break;
}
}
}
public function rules(): array
{
return [
'key' => ['required', 'string'],
'value' => ['required', 'string'],
];
}
}
+22 -32
View File
@@ -1,62 +1,62 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use App\Imports\ValidatesPortfolioAccess; use App\Imports\ValidatesPortfolioAccess;
use App\Models\DailyChange;
use App\Models\BackupImport; use App\Models\BackupImport;
use App\Models\DailyChange;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Events\BeforeSheet;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Concerns\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 DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
use ValidatesPortfolioAccess; use ValidatesPortfolioAccess;
public function __construct( public function __construct(
public BackupImport $backupImport public BackupImport $backupImport
) { } ) {}
/**
* @return array
*/
public function registerEvents(): array public function registerEvents(): array
{ {
return [ return [
BeforeSheet::class => function(BeforeSheet $event) { BeforeSheet::class => function (BeforeSheet $event) {
DB::commit(); DB::commit();
$this->backupImport->update([ $this->backupImport->update([
'message' => __('Importing daily changes...'), 'message' => __('Preparing to import daily changes...'),
]); ]);
DB::beginTransaction(); DB::beginTransaction();
} },
]; ];
} }
public function collection(Collection $dailyChanges) public function collection(Collection $dailyChanges)
{ {
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) { $totalBatches = count($dailyChanges) / $this->batchSize();
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->validatePortfolioAccess($chunk); $this->validatePortfolioAccess($chunk);
$this->backupImport->update([
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
// have to cast to native values // have to cast to native values
$chunk = $chunk->map(function ($dailyChange) { $chunk = $chunk->map(function ($dailyChange) {
return [ return [
'total_market_value' => $dailyChange['total_market_value'],
'total_cost_basis' => $dailyChange['total_cost_basis'],
'total_gain' => $dailyChange['total_gain'],
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
'realized_gains' => $dailyChange['realized_gains'],
'annotation' => $dailyChange['annotation'], 'annotation' => $dailyChange['annotation'],
'portfolio_id' => $dailyChange['portfolio_id'], 'portfolio_id' => $dailyChange['portfolio_id'],
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d') 'date' => Carbon::parse($dailyChange['date'])->toDateString(),
]; ];
}); });
@@ -64,14 +64,9 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
$chunk->toArray(), $chunk->toArray(),
['portfolio_id', 'date'], ['portfolio_id', 'date'],
[ [
'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'annotation', 'annotation',
'portfolio_id', 'portfolio_id',
'date' 'date',
] ]
); );
}); });
@@ -85,13 +80,8 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
public function rules(): array public function rules(): array
{ {
return [ return [
'portfolio_id' => ['required', 'uuid'], 'portfolio_id' => ['required', 'uuid'],
'date' => ['required', 'date'], 'date' => ['required', 'date'],
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'total_gain' => ['sometimes', 'nullable', 'numeric'],
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
'annotation' => ['sometimes', 'nullable', 'string'], 'annotation' => ['sometimes', 'nullable', 'string'],
]; ];
} }
+12 -13
View File
@@ -1,37 +1,36 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use App\Models\Portfolio;
use App\Models\BackupImport; use App\Models\BackupImport;
use App\Models\Portfolio;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Events\BeforeSheet;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Concerns\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, WithEvents class PortfoliosSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
public function __construct( public function __construct(
public BackupImport $backupImport public BackupImport $backupImport
) { } ) {}
/**
* @return array
*/
public function registerEvents(): array public function registerEvents(): array
{ {
return [ return [
BeforeSheet::class => function(BeforeSheet $event) { BeforeSheet::class => function (BeforeSheet $event) {
DB::commit(); DB::commit();
$this->backupImport->update([ $this->backupImport->update([
'message' => __('Importing portfolios...'), 'message' => __('Importing portfolios...'),
]); ]);
DB::beginTransaction(); DB::beginTransaction();
} },
]; ];
} }
@@ -42,7 +41,7 @@ class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, S
Portfolio::unguard(); // ensures we can set an owner for the portfolio Portfolio::unguard(); // ensures we can set an owner for the portfolio
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([ $portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
'id' => $portfolio['portfolio_id'] 'id' => $portfolio['portfolio_id'],
], [ ], [
'id' => $portfolio['portfolio_id'] ?? null, 'id' => $portfolio['portfolio_id'] ?? null,
'title' => $portfolio['title'], 'title' => $portfolio['title'],
+57 -30
View File
@@ -1,57 +1,81 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use App\Imports\ValidatesPortfolioAccess; use App\Imports\ValidatesPortfolioAccess;
use App\Models\BackupImport;
use App\Models\Currency;
use App\Models\CurrencyRate;
use App\Models\Holding; use App\Models\Holding;
use App\Models\Transaction; use App\Models\Transaction;
use Illuminate\Support\Str;
use App\Models\BackupImport;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Events\BeforeSheet; use Illuminate\Support\Str;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\ToCollection;
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 TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
use ValidatesPortfolioAccess; use ValidatesPortfolioAccess;
public function __construct( public function __construct(
public BackupImport $backupImport public BackupImport $backupImport
) { } ) {}
/**
* @return array
*/
public function registerEvents(): array public function registerEvents(): array
{ {
return [ return [
BeforeSheet::class => function(BeforeSheet $event) { BeforeSheet::class => function (BeforeSheet $event) {
DB::commit(); DB::commit();
$this->backupImport->update([ $this->backupImport->update([
'message' => __('Importing transactions...'), 'message' => __('Preparing to import transactions...'),
]); ]);
DB::beginTransaction(); DB::beginTransaction();
} },
]; ];
} }
public function collection(Collection $transactions) public function collection(Collection $transactions)
{ {
$transactions->chunk($this->batchSize())->each(function ($chunk) { // if has any transactions not in base currency, need to sync timeseries conversion rates
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
}
$totalBatches = count($transactions) / $this->batchSize();
// chunk transactions
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
$this->backupImport->update([
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
]);
$this->validatePortfolioAccess($chunk); $this->validatePortfolioAccess($chunk);
// have to cast to native values // have to cast to native values
$chunk = $chunk->map(function ($transaction) { $chunk = $chunk->map(function ($transaction) {
$date = Carbon::parse($transaction['date'])->toDateString();
// if transaction not in base currency, need to convert
if ($transaction['currency'] == config('investbrain.base_currency')) {
$cost_basis_base = $transaction['cost_basis'] ?? 0;
$sale_price_base = $transaction['sale_price'];
} else {
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
}
return [ return [
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(), 'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
'symbol' => strtoupper($transaction['symbol']), 'symbol' => strtoupper($transaction['symbol']),
@@ -60,9 +84,11 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
'quantity' => $transaction['quantity'], 'quantity' => $transaction['quantity'],
'cost_basis' => $transaction['cost_basis'] ?? 0, 'cost_basis' => $transaction['cost_basis'] ?? 0,
'sale_price' => $transaction['sale_price'], 'sale_price' => $transaction['sale_price'],
'cost_basis_base' => $cost_basis_base,
'sale_price_base' => $sale_price_base,
'split' => boolval($transaction['split']) ? 1 : 0, 'split' => boolval($transaction['split']) ? 1 : 0,
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0, 'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
'date' => Carbon::parse($transaction['date'])->format('Y-m-d') 'date' => $date,
]; ];
}); });
@@ -79,23 +105,23 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
'sale_price', 'sale_price',
'split', 'split',
'reinvested_dividend', 'reinvested_dividend',
'date' 'date',
] ]
); );
// stub out related holdings // get unique symbol/portfolio id combination and stub out related holdings
$chunk->unique(fn($item) => $item['symbol'] . $item['portfolio_id']) $chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
->each(function($holding) { ->each(function ($holding) {
Holding::firstOrCreate([ Holding::firstOrCreate([
'symbol' => $holding['symbol'], 'symbol' => $holding['symbol'],
'portfolio_id' => $holding['portfolio_id'] 'portfolio_id' => $holding['portfolio_id'],
], [ ], [
'quantity' => 0, 'quantity' => 0,
'average_cost_basis' => 0, 'average_cost_basis' => 0,
'splits_synced_at' => now(), 'splits_synced_at' => now(),
]); ]);
}); });
}); });
} }
@@ -114,6 +140,7 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
'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'],
'currency' => ['required', 'string'],
'split' => ['sometimes', 'nullable', 'boolean'], 'split' => ['sometimes', 'nullable', 'boolean'],
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'], 'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
+8 -7
View File
@@ -1,24 +1,25 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports; namespace App\Imports;
use App\Models\Portfolio; use App\Models\Portfolio;
trait ValidatesPortfolioAccess trait ValidatesPortfolioAccess
{ {
public function validatePortfolioAccess($collection) public function validatePortfolioAccess($collection)
{ {
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id'); $importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id) $portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
->whereIn('id', $uniquePortfolios) ->whereIn('id', $importingPortfolios)
->count(); ->count();
if ( if (
$countPortfoliosWithAccess < $uniquePortfolios->count() $importingPortfolios->count() > $portfoliosWithAccess
) { ) {
throw new \Exception(__("You do not have access to that portfolio.")); throw new \Exception(__('You do not have access to that portfolio.'));
} }
} }
} }
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonInterval;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AlpacaMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $dataBaseUrl = 'https://data.alpaca.markets/';
public string $apiBaseUrl = 'https://api.alpaca.markets/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
'Apca-Api-Key-Id' => config('alpaca.key'),
'Apca-Api-Secret-Key' => config('alpaca.secret'),
],
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client->baseUrl($this->dataBaseUrl)->get("v2/stocks/{$symbol}/trades/latest");
$quote = $response->json('trade');
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$fundamental = cache()->remember(
'ap-symbol-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '12M',
'start' => now()->subWeeks(53)->format('Y-m-d'),
'end' => now()->subWeeks(1)->format('Y-m-d'), // todo: can't query recent SIP data
])->get("v2/stocks/{$symbol}/bars")->json();
return array_merge($fifty_two_week, $basic);
}
);
return new Quote([
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => 'USD', // Alpaca only has US equitities
'market_value' => Arr::get($quote, 'p'),
'fifty_two_week_high' => Arr::get($fundamental, 'bars.0.h'),
'fifty_two_week_low' => Arr::get($fundamental, 'bars.0.l'),
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'symbols' => $symbol,
'limit' => 1000,
'sort' => 'asc',
'types' => 'cash_dividend',
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
])->get('v1/corporate-actions');
$dividends = $response->json('corporate_actions.cash_dividends');
return collect($dividends)
->map(function ($dividend) use ($symbol) {
return new Dividend([
'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'ex_date')),
'dividend_amount' => Arr::get($dividend, 'rate'),
]);
});
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'symbols' => $symbol,
'limit' => 1000,
'sort' => 'asc',
'types' => 'forward_split',
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
])->get('v1/corporate-actions');
$splits = $response->json('corporate_actions.forward_splits');
return collect($splits)
->map(function ($split) use ($symbol) {
return new Split([
'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'ex_date')),
'split_amount' => Arr::get($split, 'new_rate') / Arr::get($split, 'old_rate'),
]);
});
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$startDate = Carbon::parse($startDate);
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
$allHistory = collect();
$chunks = 1000;
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
foreach ($period as $startDate) {
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
if ($chunkEnd->gt($endDate)) {
$chunkEnd = $endDate;
}
$this->createNewClient();
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D',
'start' => $startDate->format('Y-m-d'),
'end' => $chunkEnd->format('Y-m-d'),
])->get("v2/stocks/{$symbol}/bars");
$history = $response->json('bars');
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$chunkedHistory = collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d');
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => Arr::get($history, 'c'),
])];
});
$allHistory = $allHistory->merge($chunkedHistory);
}
return $allHistory;
}
}
@@ -1,45 +1,66 @@
<?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;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use Tschucki\Alphavantage\Facades\Alphavantage; 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): Quote public function quote(string $symbol): Quote
{ {
$search = Alphavantage::core()->search($symbol);
$search = Arr::get($search, 'bestMatches.0', null);
if (Arr::get($search, '9. matchScore') !== '1.0000') {
throw new \Exception('Could not find ticker on Alphavantage');
}
$quote = Alphavantage::core()->quoteEndpoint($symbol); $quote = Alphavantage::core()->quoteEndpoint($symbol);
$quote = Arr::get($quote, 'Global Quote', []); $quote = Arr::get($quote, 'Global Quote', []);
if (empty($quote)) return new Quote();
$fundamental = cache()->remember( $fundamental = cache()->remember(
'av-symbol-'.$symbol, 'av-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol, $search) {
return Alphavantage::fundamentals()->overview($symbol); if (Arr::get($search, '3. type') === 'Equity') {
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
} else {
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
}
return $fundamental;
} }
); );
return new Quote([ return new Quote([
'name' => Arr::get($fundamental, 'Name'), 'name' => Arr::get($search, '2. name'),
'symbol' => Arr::get($fundamental, 'Symbol'), 'symbol' => $symbol,
'market_value' => Arr::get($quote, '05. price'), 'market_value' => (float) Arr::get($quote, '05. price'),
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'), 'currency' => Arr::get($search, '8. currency'),
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'), 'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'ForwardPE'), 'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'), 'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'), 'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
@@ -48,72 +69,84 @@ class AlphaVantageMarketData implements MarketDataInterface
? Arr::get($fundamental, 'DividendDate') ? Arr::get($fundamental, 'DividendDate')
: null, : null,
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None' 'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
? Arr::get($fundamental, 'DividendYield') ? ((float) Arr::get($fundamental, 'DividendYield')) * 100
: null : null,
]); 'meta_data' => [
'industry' => Arr::get($fundamental, 'Industry'),
'country' => Arr::get($search, '4. region'),
'exchange' => Arr::get($fundamental, 'Exchange'),
'description' => Arr::get($fundamental, 'Description'),
'asset_type' => Arr::get($search, '3. type'),
'sector' => Arr::get($fundamental, 'Sector'),
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
: null,
'source' => 'alphavantage',
],
]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
$dividends = Alphavantage::fundamentals()->dividends($symbol); $dividends = 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 new Dividend([ 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')),
'dividend_amount' => Arr::get($dividend, 'amount'), 'dividend_amount' => (float) 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 new Split([ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'effective_date')), 'date' => Carbon::parse(Arr::get($split, 'effective_date')),
'split_amount' => Arr::get($split, 'split_factor'), 'split_amount' => (float) 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)->toDateString();
return [ $date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => Arr::get($history, '4. close') 'close' => (float) Arr::get($history, '4. close'),
]) ]; ])];
}); });
} }
} }
+34 -18
View File
@@ -1,28 +1,32 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Dividend; use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonPeriod;
use Illuminate\Support\Carbon;
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): Quote public function quote(string $symbol): Quote
{ {
return new Quote([ return new Quote([
'name' => 'ACME Company Ltd', 'name' => 'ACME Company Ltd',
'symbol' => $symbol, 'symbol' => $symbol,
'currency' => 'USD',
'market_value' => 230.19, 'market_value' => 230.19,
'fifty_two_week_high' => 512.90, 'fifty_two_week_high' => 512.90,
'fifty_two_week_low' => 341.20, 'fifty_two_week_low' => 341.20,
@@ -31,11 +35,12 @@ 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' => 0.033 'dividend_yield' => 0.033,
'meta_data' => [],
]); ]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
return collect([ return collect([
@@ -57,33 +62,44 @@ class FakeMarketData implements MarketDataInterface
]); ]);
} }
public function splits(String $symbol, $startDate, $endDate): Collection public function splits(string $symbol, $startDate, $endDate): Collection
{ {
return collect([ return collect([
new Split([ new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(36), 'date' => now()->subMonths(12),
'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); $endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
? now()->subDay()
: now();
for ($i = 0; $i < $numDays; $i++) { $days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
$date = now()->subDays($i)->format('Y-m-d'); $countOfDays = $days->count();
foreach ($days as $index => $date) {
$date = $date->toDateString();
$series[$date] = new Ohlc([ $series[$date] = new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => rand(150, 400), 'open' => rand(150, 400),
'high' => rand(150, 400),
'low' => rand(150, 400),
'close' => $index == $countOfDays - 1
? 230.19 // most recent close should match current market value
: rand(150, 400),
]); ]);
} }
return collect($series); return collect($series);
} }
} }
@@ -1,26 +1,29 @@
<?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);
$symbol = $arguments[0];
try { try {
Log::info("Calling method {$method} for {$symbol} ({$provider})");
if (!in_array($provider, array_keys(config('investbrain.interfaces', [])))) { if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
throw new \Exception("Provider [{$provider}] is not a valid market data interface."); throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
} }
@@ -30,13 +33,20 @@ class FallbackInterface
return app()->make($provider_class_name)->$method(...$arguments); 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::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
} }
} }
throw new \Exception("Could not get market data: {$this->latest_error}"); // don't need to throw error if calling exists method...
if ($method == 'exists') {
// symbol prob just doesn't exist
return false;
}
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
} }
} }
+53 -36
View File
@@ -1,14 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use Illuminate\Support\Arr; use App\Interfaces\MarketData\Types\Dividend;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use App\Interfaces\MarketData\Types\Dividend; use Finnhub\ObjectSerializer;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class FinnhubMarketData implements MarketDataInterface class FinnhubMarketData implements MarketDataInterface
{ {
@@ -16,53 +19,66 @@ 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(string $symbol): Quote public function quote(string $symbol): Quote
{ {
$quote = $this->client->quote($symbol); $quote = $this->client->quote($symbol);
if (empty($quote)) return new Quote(); if (is_null(Arr::get($quote, 'd'))) {
throw new \Exception('Could not find ticker on Finnhub');
}
$fundamental = cache()->remember( $fundamental = cache()->remember(
'fh-symbol-'.$symbol, 'fh-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol) {
return $this->client->companyBasicFinancials($symbol, "all");
return array_merge(
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
);
} }
); );
return new Quote([ return new Quote([
'name' => Arr::get($fundamental, 'metric.name'), 'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol, 'symbol' => $symbol,
'market_value' => Arr::get($quote, 'c'), 'currency' => Arr::get($fundamental, 'currency'),
'market_value' => Arr::get($quote, 'c'),
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'), 'fifty_two_week_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.peAnnual'),
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm 'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm 'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm 'meta_data' => [
]); 'country' => Arr::get($fundamental, 'country'),
'exchange' => Arr::get($fundamental, 'exchange'),
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
'source' => 'finnhub',
],
]);
} }
public function dividends($symbol, $startDate, $endDate): Collection 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->toDateString(), $endDate->toDateString());
return collect($dividends)->map(function($dividend) use ($symbol) { return collect($dividends)->map(function ($dividend) use ($symbol) {
return new Dividend([ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'date')), 'date' => Carbon::parse(Arr::get($dividend, 'date')),
@@ -72,12 +88,12 @@ class FinnhubMarketData implements MarketDataInterface
} }
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->toDateString(), $endDate->toDateString());
return collect($splits)->map(function ($split) use ($symbol) {
return collect($splits)->map(function($split) use ($symbol) {
return new Split([ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'date')), 'date' => Carbon::parse(Arr::get($split, 'date')),
@@ -89,18 +105,19 @@ class FinnhubMarketData implements MarketDataInterface
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)->toDateString();
return [ $date => new Ohlc([
return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => $closes[$index], 'close' => $closes[$index],
]) ]; ])];
}); });
} }
} }
@@ -1,60 +1,36 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use Illuminate\Support\Collection;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
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 Quote
*/ */
public function quote(String $symbol): Quote; 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,170 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TwelveDataMarketData implements MarketDataInterface
{
public PendingRequest $client;
public string $apiBaseUrl = 'https://api.twelvedata.com/';
public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{
$this->client = Http::withOptions([
'headers' => [
'content-type' => 'application/json',
'accept' => 'application/json',
],
])->withQueryParameters([
'apikey' => config('twelvedata.secret'),
]);
}
public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('price');
$quote = $response->json();
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
$current_market_value = Arr::get($quote, 'price');
$fundamental = cache()->remember(
'twelve-data-symbol-'.$symbol,
1440,
function () use ($symbol) {
$this->createNewClient();
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters(['symbol' => $symbol])
->get('quote');
return $response->json();
}
);
return new Quote([
'name' => Arr::get($fundamental, 'name'),
'symbol' => $symbol,
'currency' => Arr::get($fundamental, 'currency'),
'market_value' => (float) $current_market_value,
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
'meta_data' => [
'exchange' => Arr::get($fundamental, 'exchange'),
'source' => 'twelvedata',
],
]);
}
public function dividends(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('dividends');
$dividends = $response->json('dividends');
return collect($dividends)
->map(function ($dividend) use ($symbol) {
return new Dividend([
'symbol' => $symbol,
'date' => Arr::get($dividend, 'ex_date'),
'dividend_amount' => Arr::get($dividend, 'amount'),
]);
});
}
public function splits(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('splits');
$splits = $response->json('splits');
return collect($splits)
->map(function ($split) use ($symbol) {
return new Split([
'symbol' => $symbol,
'date' => Arr::get($split, 'date'),
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
]);
});
}
public function history(string $symbol, $startDate, $endDate): Collection
{
$response = $this->client
->baseUrl($this->apiBaseUrl)
->withQueryParameters([
'symbol' => $symbol,
'interval' => '1day',
'start_date' => Carbon::parse($startDate)->toDateString(),
'end_date' => Carbon::parse($endDate)->toDateString(),
])
->get('time_series');
$history = $response->json('values');
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
return collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
return [$date => new Ohlc([
'symbol' => $symbol,
'date' => $date,
'close' => (float) Arr::get($history, 'close'),
])];
});
}
}
+8 -4
View File
@@ -1,16 +1,18 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use DateTime; use DateTime;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Interfaces\MarketData\Types\MarketDataType;
class Dividend extends MarketDataType class Dividend extends MarketDataType
{ {
public function setSymbol(string $symbol): self public function setSymbol(string $symbol): self
{ {
$this->items['symbol'] = $symbol; $this->items['symbol'] = $symbol;
return $this; return $this;
} }
@@ -19,9 +21,10 @@ class Dividend extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setDividendAmount($dividendAmount): self public function setDividendAmount(int|float $dividendAmount): self
{ {
$this->items['dividend_amount'] = (float) $dividendAmount; $this->items['dividend_amount'] = (float) $dividendAmount;
return $this; return $this;
} }
@@ -30,9 +33,10 @@ class Dividend extends MarketDataType
return $this->items['dividend_amount'] ?? 0.0; return $this->items['dividend_amount'] ?? 0.0;
} }
public function setDate(String|DateTime $date): self public function setDate(string|DateTime $date): self
{ {
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s'); $this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this; return $this;
} }
@@ -40,4 +44,4 @@ class Dividend extends MarketDataType
{ {
return $this->items['date'] ?? null; return $this->items['date'] ?? null;
} }
} }
@@ -1,36 +1,91 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use Illuminate\Support\Str; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class MarketDataType extends Collection class MarketDataType extends Collection
{ {
/**
*
*/
public function __construct($items = []) public function __construct($items = [])
{ {
foreach($this->getArrayableItems($items) as $key => $value) { $items = $this->getArrayableItems($items);
$this->{$key} = $value; foreach ($items as $key => $value) {
$this->validateRequiredTypes($key, $value);
if (! is_null($value)) {
$this->{$key} = $value;
}
} }
} }
public function toArray()
{
return $this->items;
}
public function __set($key, $value) public function __set($key, $value)
{ {
$this->{'set'.Str::studly($key)}($value);
$this->{$this->getSetMethodName($key)}($value);
} }
public function __get($key) public function __get($key)
{ {
return $this->items[$key] ?? null; return $this->items[$key] ?? null;
} }
}
protected function getSetMethodName($key): string
{
return 'set'.Str::studly($key);
}
protected function validateRequiredTypes($key, $value, $type = null): void
{
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
$params = $method->getParameters();
// no required type
if (is_null($type) && is_null($type = $params[0]->getType())) {
return;
}
// can`t validate a mixed type
if ($type == 'mixed') {
return;
}
// has a union type, let's iterate
if ($type instanceof \ReflectionUnionType) {
foreach ($type->getTypes() as $subType) {
$expected[] = $subType;
try {
$this->validateRequiredTypes($key, $value, $subType);
return;
} catch (\InvalidArgumentException) {
}
}
}
// check type
if ($type instanceof \ReflectionNamedType) {
$expected = $type->getName();
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
return;
}
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
return;
}
}
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
}
}
+14 -7
View File
@@ -1,16 +1,18 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use DateTime; use DateTime;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Interfaces\MarketData\Types\MarketDataType;
class Ohlc extends MarketDataType class Ohlc extends MarketDataType
{ {
public function setSymbol(string $symbol): self public function setSymbol(string $symbol): self
{ {
$this->items['symbol'] = $symbol; $this->items['symbol'] = $symbol;
return $this; return $this;
} }
@@ -19,9 +21,10 @@ class Ohlc extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setOpen($open): self public function setOpen(int|float $open): self
{ {
$this->items['open'] = (float) $open; $this->items['open'] = (float) $open;
return $this; return $this;
} }
@@ -30,9 +33,10 @@ class Ohlc extends MarketDataType
return $this->items['open'] ?? 0.0; return $this->items['open'] ?? 0.0;
} }
public function setHigh($high): self public function setHigh(int|float $high): self
{ {
$this->items['high'] = (float) $high; $this->items['high'] = (float) $high;
return $this; return $this;
} }
@@ -41,9 +45,10 @@ class Ohlc extends MarketDataType
return $this->items['high'] ?? 0.0; return $this->items['high'] ?? 0.0;
} }
public function setLow($low): self public function setLow(int|float $low): self
{ {
$this->items['low'] = (float) $low; $this->items['low'] = (float) $low;
return $this; return $this;
} }
@@ -52,9 +57,10 @@ class Ohlc extends MarketDataType
return $this->items['low'] ?? 0.0; return $this->items['low'] ?? 0.0;
} }
public function setClose($close): self public function setClose(int|float $close): self
{ {
$this->items['close'] = (float) $close; $this->items['close'] = (float) $close;
return $this; return $this;
} }
@@ -63,9 +69,10 @@ class Ohlc extends MarketDataType
return $this->items['close'] ?? 0.0; return $this->items['close'] ?? 0.0;
} }
public function setDate(String|DateTime $date): self public function setDate(string|DateTime $date): self
{ {
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s'); $this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this; return $this;
} }
@@ -73,4 +80,4 @@ class Ohlc extends MarketDataType
{ {
return $this->items['date'] ?? null; return $this->items['date'] ?? null;
} }
} }
+84 -12
View File
@@ -1,16 +1,21 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use DateTime; use DateTime;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Interfaces\MarketData\Types\MarketDataType;
class Quote extends MarketDataType class Quote extends MarketDataType
{ {
public function setName($name): self public function setName($name): self
{ {
$this->items['name'] = (string) $name; if (! empty($name)) {
$this->items['name'] = (string) $name;
}
return $this; return $this;
} }
@@ -19,9 +24,10 @@ class Quote extends MarketDataType
return $this->items['name'] ?? ''; return $this->items['name'] ?? '';
} }
public function setSymbol($symbol): self public function setSymbol(string $symbol): self
{ {
$this->items['symbol'] = (string) $symbol; $this->items['symbol'] = (string) $symbol;
return $this; return $this;
} }
@@ -30,9 +36,30 @@ class Quote extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setMarketValue($marketValue): self public function setCurrency(string $currency): self
{
// need to standardize to ISO 4217
$currency = match ($currency) {
'US' => 'USD',
'CA' => 'CAD',
'GBp' => 'GBX',
default => $currency
};
$this->items['currency'] = strtoupper((string) $currency);
return $this;
}
public function getCurrency(): string
{
return $this->items['currency'] ?? '';
}
public function setMarketValue(int|float $marketValue): self
{ {
$this->items['market_value'] = (float) $marketValue; $this->items['market_value'] = (float) $marketValue;
return $this; return $this;
} }
@@ -41,9 +68,10 @@ class Quote extends MarketDataType
return $this->items['market_value'] ?? 0.0; return $this->items['market_value'] ?? 0.0;
} }
public function setFiftyTwoWeekHigh($high): self public function setFiftyTwoWeekHigh($high): self
{ {
$this->items['fifty_two_week_high'] = (float) $high; $this->items['fifty_two_week_high'] = (float) $high;
return $this; return $this;
} }
@@ -52,9 +80,10 @@ class Quote extends MarketDataType
return $this->items['fifty_two_week_high'] ?? 0.0; return $this->items['fifty_two_week_high'] ?? 0.0;
} }
public function setFiftyTwoWeekLow($low): self public function setFiftyTwoWeekLow($low): self
{ {
$this->items['fifty_two_week_low'] = (float) $low; $this->items['fifty_two_week_low'] = (float) $low;
return $this; return $this;
} }
@@ -63,9 +92,10 @@ class Quote extends MarketDataType
return $this->items['fifty_two_week_low'] ?? 0.0; return $this->items['fifty_two_week_low'] ?? 0.0;
} }
public function setForwardPE($pe): self public function setForwardPE($pe): self
{ {
$this->items['forward_pe'] = (float) $pe; $this->items['forward_pe'] = (float) $pe;
return $this; return $this;
} }
@@ -74,9 +104,10 @@ class Quote extends MarketDataType
return $this->items['forward_pe'] ?? 0.0; return $this->items['forward_pe'] ?? 0.0;
} }
public function setTrailingPE($pe): self public function setTrailingPE($pe): self
{ {
$this->items['trailing_pe'] = (float) $pe; $this->items['trailing_pe'] = (float) $pe;
return $this; return $this;
} }
@@ -87,7 +118,9 @@ class Quote extends MarketDataType
public function setMarketCap($cap): self public function setMarketCap($cap): self
{ {
// return $this;
$this->items['market_cap'] = (int) $cap; $this->items['market_cap'] = (int) $cap;
return $this; return $this;
} }
@@ -96,9 +129,10 @@ class Quote extends MarketDataType
return $this->items['market_cap'] ?? 0; return $this->items['market_cap'] ?? 0;
} }
public function setBookValue($value): self public function setBookValue($value): self
{ {
$this->items['book_value'] = (float) $value; $this->items['book_value'] = (float) $value;
return $this; return $this;
} }
@@ -107,9 +141,22 @@ class Quote extends MarketDataType
return $this->items['book_value'] ?? 0.0; return $this->items['book_value'] ?? 0.0;
} }
public function setLastDividendAmount($value): self
{
$this->items['last_dividend_amount'] = (float) $value;
return $this;
}
public function getLastDividendAmount(): float
{
return $this->items['last_dividend_amount'] ?? 0.0;
}
public function setLastDividendDate(mixed $date): self 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'); $this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
return $this; return $this;
} }
@@ -118,9 +165,10 @@ class Quote extends MarketDataType
return $this->items['last_dividend_date'] ?? null; return $this->items['last_dividend_date'] ?? null;
} }
public function setDividendYield($yield): self public function setDividendYield($yield): self
{ {
$this->items['dividend_yield'] = (float) $yield; $this->items['dividend_yield'] = (float) $yield;
return $this; return $this;
} }
@@ -128,4 +176,28 @@ class Quote extends MarketDataType
{ {
return $this->items['dividend_yield'] ?? 0.0; return $this->items['dividend_yield'] ?? 0.0;
} }
}
public function setMetaData(array $meta_data): self
{
$defaults = [
'sector' => null,
'industry' => null,
'country' => null,
'exchange' => null,
'description' => null,
'asset_type' => null,
'first_trade_year' => null,
'source' => null,
];
// merges the NEW values with highest priority over previous values and defaults
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
return $this;
}
public function getMetaData(): array
{
return $this->items['meta_data'];
}
}
+8 -4
View File
@@ -1,16 +1,18 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types; namespace App\Interfaces\MarketData\Types;
use DateTime; use DateTime;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use App\Interfaces\MarketData\Types\MarketDataType;
class Split extends MarketDataType class Split extends MarketDataType
{ {
public function setSymbol(string $symbol): self public function setSymbol(string $symbol): self
{ {
$this->items['symbol'] = $symbol; $this->items['symbol'] = $symbol;
return $this; return $this;
} }
@@ -19,9 +21,10 @@ class Split extends MarketDataType
return $this->items['symbol'] ?? ''; return $this->items['symbol'] ?? '';
} }
public function setSplitAmount($splitAmount): self public function setSplitAmount(int|float $splitAmount): self
{ {
$this->items['split_amount'] = (float) $splitAmount; $this->items['split_amount'] = (float) $splitAmount;
return $this; return $this;
} }
@@ -30,9 +33,10 @@ class Split extends MarketDataType
return $this->items['split_amount'] ?? 0.0; return $this->items['split_amount'] ?? 0.0;
} }
public function setDate(String|DateTime $date): self public function setDate(string|DateTime $date): self
{ {
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s'); $this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this; return $this;
} }
@@ -40,4 +44,4 @@ class Split extends MarketDataType
{ {
return $this->items['date'] ?? null; return $this->items['date'] ?? null;
} }
} }
+67 -52
View File
@@ -1,95 +1,110 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use Illuminate\Support\Collection; use App\Interfaces\MarketData\Types\Dividend;
use Scheb\YahooFinanceApi\ApiClient;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use App\Interfaces\MarketData\Types\Dividend; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Scheb\YahooFinanceApi\ApiClient;
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance; use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
class YahooMarketData implements MarketDataInterface class YahooMarketData implements MarketDataInterface
{ {
public ApiClient $client; public ApiClient $client;
public function __construct() { public function __construct()
// create yahoo finance client factory
$this->client = YahooFinance::createApiClient();
}
public function exists(String $symbol): Bool
{ {
return $this->quote($symbol)->isNotEmpty(); // create yahoo finance client factory
$this->client = YahooFinance::createApiClient(
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
cache: app('cache.psr6')
);
} }
public function quote(String $symbol): Quote public function exists(string $symbol): bool
{
return (bool) $this->quote($symbol);
}
public function quote(string $symbol): Quote
{ {
$quote = $this->client->getQuote($symbol); $quote = $this->client->getQuote($symbol);
if (empty($quote)) return collect(); if (is_null($quote?->getRegularMarketPrice())) {
throw new \Exception('Could not find ticker on Yahoo');
}
return new Quote([ return new Quote([
'name' => $quote->getLongName() ?? $quote->getShortName(), 'name' => $quote?->getLongName() ?? $quote?->getShortName(),
'symbol' => $quote->getSymbol(), 'symbol' => $symbol,
'market_value' => $quote->getRegularMarketPrice(), 'currency' => $quote?->getCurrency(),
'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,
'meta_data' => [
'exchange' => $quote?->getExchange(),
'asset_type' => $quote?->getQuoteType(),
'source' => 'yahoo',
],
]); ]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate)) return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
->map(function($dividend) use ($symbol) { ->map(function ($dividend) use ($symbol) {
return new Dividend([ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $dividend->getDate(), '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 new Split([ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $split->getDate(), '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 = Carbon::parse($history->getDate())->toDateString();
return [ $date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => $history->getClose(), 'close' => $history->getClose(),
]) ]; ])];
}); });
} }
} }
+15 -13
View File
@@ -1,16 +1,18 @@
<?php <?php
declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use Throwable;
use App\Models\User;
use App\Models\BackupImport;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Notifications\ImportSucceededNotification;
use App\Notifications\ImportFailedNotification;
use App\Imports\BackupImport as BackupImportExcel; use App\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 class BackupImportJob implements ShouldQueue
{ {
@@ -19,7 +21,7 @@ class BackupImportJob implements ShouldQueue
/** /**
* The number of times the job may be attempted. * The number of times the job may be attempted.
*/ */
public $tries = 1; public $tries = 1;
/** /**
* The number of seconds the job can run before timing out. * The number of seconds the job can run before timing out.
@@ -42,7 +44,7 @@ class BackupImportJob implements ShouldQueue
*/ */
public function __construct( public function __construct(
public BackupImport $backupImport public BackupImport $backupImport
) { ) {
$this->user = User::find($this->backupImport->user_id); $this->user = User::find($this->backupImport->user_id);
} }
@@ -50,7 +52,7 @@ class BackupImportJob implements ShouldQueue
* Execute the job. * Execute the job.
*/ */
public function handle(): void public function handle(): void
{ {
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null)); Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
$this->user->notify(new ImportSucceededNotification); $this->user->notify(new ImportSucceededNotification);
@@ -63,9 +65,9 @@ class BackupImportJob implements ShouldQueue
{ {
$this->backupImport->update([ $this->backupImport->update([
'status' => 'failed', 'status' => 'failed',
'message' => 'Error: '. substr($e->getMessage(), 0, 220), 'message' => 'Error: '.substr($e->getMessage(), 0, 220),
'has_errors' => true, 'has_errors' => true,
'completed_at' => now() 'completed_at' => now(),
]); ]);
$this->user->notify(new ImportFailedNotification($e->getMessage())); $this->user->notify(new ImportFailedNotification($e->getMessage()));
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\CurrencyRate;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class QueuedCurrencyRateInsertJob implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
public function __construct(
protected array $chunk
) {
$this->chunk = $chunk;
}
/**
* Execute the job.
*/
public function handle(): void
{
CurrencyRate::insertOrIgnore($this->chunk);
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Datatables;
use App\Models\Holding;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class HoldingsTable extends DataTableComponent
{
public $portfolio;
public array $hiddenColumns = [];
public function mount($portfolio): void
{
//
}
public function builder(): Builder
{
return Holding::query()
->portfolio($this->portfolio->id)
->with(['market_data'])
->withCount(['transactions as num_transactions' => function ($query) {
return $query->whereRaw('transactions.symbol = holdings.symbol');
}])
->withPerformance();
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'average_cost_basis', 'market_value', 'fifty_two_week_low', 'fifty_two_week_high'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll',
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer',
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25',
]);
$this->setTdAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('symbol', 'asc');
$this->setToolsDisabled();
$this->setFooterDisabled();
$this->setPaginationDisabled();
$this->setDisplayPaginationDetailsDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function ($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function ($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Average Cost Basis'), 'average_cost_basis')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Total Cost Basis'), 'total_cost_basis')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Market Value'), 'market_data.market_value')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Total Market Value'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('total_market_value', $direction))
->label(fn ($row) => Number::currency($row->total_market_value ?? 0, $row->market_data?->currency)),
Column::make(__('Market Gain/Loss'))
->html()
->label(fn ($row) => Number::currency($row->market_gain_dollars ?? 0, $row->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
'costBasis' => $row->average_cost_basis,
'marketValue' => $row->market_data?->market_value,
'small' => true,
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('market_gain_dollars', $direction)),
Column::make(__('Realized Gain/Loss'), 'realized_gain_dollars')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Dividends Earned'), 'dividends_earned')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('52 week low'), 'market_data.fifty_two_week_low')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('52 week high'), 'market_data.fifty_two_week_high')
->sortable()
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
Column::make(__('Number of Transactions'))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('num_transactions', $direction))
->label(fn ($row) => $row->num_transactions),
Column::make(__('Last Refreshed'), 'market_data.updated_at')
->sortable()
->format(fn ($value) => \Carbon\Carbon::parse($value)->diffForHumans()),
];
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Datatables;
use App\Models\Transaction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Number;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
class TransactionsTable extends DataTableComponent
{
public array $hiddenColumns = [];
public function mount(): void
{
//
}
public function builder(): Builder
{
return Transaction::query()
->with(['portfolio', 'market_data'])
->myTransactions()
->addSelect(['portfolio_id', 'transaction_type', 'split', 'cost_basis'])
->selectRaw('
(CASE
WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price, 0)
ELSE COALESCE(market_data.market_value, 0)
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars');
}
public function configure(): void
{
$this->hiddenColumns = ['name', 'cost_basis', 'gain_dollars'];
$this->setTableWrapperAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'overflow-scroll',
]);
$this->setTableAttributes([
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'table',
]);
$this->setTheadAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setThAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setThSortButtonAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer',
]);
$this->setTbodyAttributes([
'default' => false,
'default-styling' => true,
'default-colors' => false,
]);
$this->setTrAttributes(fn () => [
'default' => false,
'default-styling' => true,
'default-colors' => false,
'class' => 'cursor-pointer hover:bg-neutral/25',
]);
$this->setTdAttributes(function (Column $column) {
$attributes = [
'default' => false,
'default-styling' => false,
'default-colors' => false,
'class' => 'text-nowrap',
];
if (in_array($column->getField(), $this->hiddenColumns)) {
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
}
return $attributes;
});
$this->setDefaultSort('date', 'desc');
$this->setPerPageAccepted([10, 15, 20]);
$this->setPerPage(15);
$this->setSearchDisabled();
$this->setColumnSelectDisabled();
$this->setPerPageVisibilityDisabled();
$this->setFooterDisabled();
$this->setPrimaryKey('id');
$this->setTableRowUrl(function ($row) {
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
})->setTableRowUrlTarget(function ($row) {
return 'navigate';
});
}
public function columns(): array
{
return [
Column::make(__('Date'), 'date')
->sortable()
->format(fn ($value) => \Carbon\Carbon::parse($value)->format('M d, Y')),
Column::make(__('Portfolio'), 'portfolio.title')
->sortable(),
Column::make(__('Symbol'), 'symbol')
->sortable(),
Column::make(__('Name'), 'market_data.name')
->sortable(),
Column::make(__('Type'), 'transaction_type')
->label(fn ($row) => view('components.ui.badge', [
'value' => $row->split ? 'SPLIT'
: ($row->reinvested_dividend
? 'REINVEST'
: $row->transaction_type),
'class' => ($row->transaction_type == 'BUY'
? 'badge-success'
: 'badge-error').' badge-sm mr-3',
]))
->sortable(fn (Builder $query, string $direction) => $query->orderBy('transaction_type', $direction)),
Column::make(__('Quantity'), 'quantity')
->sortable(),
Column::make(__('Cost Basis'), 'cost_basis')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('cost_basis', $direction))
->label(fn ($row) => Number::currency($row->cost_basis ?? 0, $row->market_data->currency)),
Column::make(__('Gain/Loss'), 'gain_dollars')
->sortable(fn (Builder $query, string $direction) => $query->orderBy('gain_dollars', $direction))
->label(fn ($row) => Number::currency($row->gain_dollars ?? 0, $row->market_data->currency)),
];
}
}
+6 -3
View File
@@ -1,9 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class AiChat extends Model class AiChat extends Model
{ {
@@ -11,7 +13,7 @@ class AiChat extends Model
protected $fillable = [ protected $fillable = [
'role', 'role',
'content' 'content',
]; ];
protected $hidden = []; protected $hidden = [];
@@ -26,7 +28,8 @@ class AiChat extends Model
}); });
} }
public function user() { public function user()
{
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
+12 -7
View File
@@ -1,12 +1,12 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Database\Eloquent\Model;
use App\Imports\BackupImport as BackupImportExcel;
use App\Jobs\BackupImportJob; use App\Jobs\BackupImportJob;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class BackupImport extends Model class BackupImport extends Model
{ {
@@ -20,7 +20,7 @@ class BackupImport extends Model
'status', // pending, in_progress, success, failed 'status', // pending, in_progress, success, failed
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully 'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
'has_errors', 'has_errors',
'completed_at' 'completed_at',
]; ];
protected static function boot() protected static function boot()
@@ -32,9 +32,9 @@ class BackupImport extends Model
$import->status = 'pending'; $import->status = 'pending';
$import->message = __('Import starting...'); $import->message = __('Import starting...');
}); });
static::created(function ($import) { static::created(function ($import) {
BackupImportJob::dispatch($import); BackupImportJob::dispatch($import);
}); });
} }
@@ -47,7 +47,12 @@ class BackupImport extends Model
{ {
return [ return [
'has_errors' => 'boolean', 'has_errors' => 'boolean',
'completed_at' => 'datetime' 'completed_at' => 'datetime',
]; ];
} }
public function user()
{
return $this->belongsTo(User::class);
}
} }
+4 -2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasTimestamps; use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
@@ -29,7 +31,7 @@ class ConnectedAccount extends Model
]; ];
protected $with = [ protected $with = [
'user' 'user',
]; ];
/** /**
@@ -52,4 +54,4 @@ class ConnectedAccount extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
} }
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Number;
class Currency extends Model
{
protected $hidden = [];
protected $primaryKey = 'currency';
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'currency',
'label',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
{
$symbol = Number::currencySymbol($currency, $locale);
return $symbol.Number::forHumans($number);
}
/**
* Returns a list of supported currencies
*
* @param bool|null $withAliases Whether to include aliases in list of currencies
*/
public static function list(?bool $withAliases = true): Collection
{
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
return [
'currency' => $currency,
'label' => $value['label'],
];
})->values() : collect();
return $aliases->merge(self::get()->map->only(['currency', 'label']));
}
/**
* Converts between supported currencies
*
* @param string|null $to (defaults to base currency)
*/
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
{
if (empty($value)) {
return 0;
}
// Assume converting to base
if (empty($to)) {
$to = config('investbrain.base_currency');
}
// Get rate
[$from, $to] = [
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
return CurrencyRate::historic($from, $date);
}),
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
return CurrencyRate::historic($to, $date);
}),
];
// get from rate
$rate_to_base = 1 / $from;
// get value in base currency
$base_currency_value = $value * $rate_to_base;
return (float) $base_currency_value * $to;
}
}
+298
View File
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Jobs\QueuedCurrencyRateInsertJob;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Investbrain\Frankfurter\Frankfurter;
class CurrencyRate extends Model
{
protected $hidden = [];
protected $primaryKey = 'currency';
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'date',
'currency',
'rate',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'rate' => 'float',
'date' => 'date',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public static function current(string $currency): float
{
return (float) self::historic($currency);
}
/**
* Get historic rate for symbol
*/
public static function historic(string $currency, mixed $date = null): float
{
// No need to convert
if ($currency === config('investbrain.base_currency')) {
return 1;
}
// If we don't need historic, let's use current rate
if (empty($date)) {
$date = now();
}
// Make sure we have a Carbon date
$date = Carbon::parse($date);
// Handle aliases
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
// Get or create historic rate
$rate = self::select('rate')
->whereDate('date', $date->toDateString())
->where(['currency' => $currency])
->firstOr(function () use ($date, $currency) {
$currencies = Currency::all()->pluck('currency')->toArray();
$rates = Frankfurter::setSymbols($currencies)->historical($date);
$date = Arr::get($rates, 'date');
$updates = Arr::map(Arr::get($rates, 'rates', []), function ($rate, $curr) use ($date) {
return [
'currency' => $curr,
'date' => $date,
'rate' => $rate,
'updated_at' => now()->toDateTimeString(),
'created_at' => now()->toDateTimeString(),
];
});
// persist
self::chunkInsert($updates);
return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]);
});
return (float) $rate->rate * $adjustment;
}
/**
* Get rates for range of dates
*
* @return array<string, float>
*/
public static function timeSeriesRates(string|array|null $currency = null, mixed $start = null, mixed $end = null): array
{
if (empty($start)) {
return [];
}
$end = $end ?? now();
$period = CarbonPeriod::create($start, $end);
// No need to send network request - just generate 1s
if ($currency === config('investbrain.base_currency')) {
$dateRange = [];
foreach ($period as $date) {
$dateRange[$date->toDateString()] = 1;
}
return $dateRange;
}
if (is_array($currency)) {
$i = 1;
foreach ($currency as $curr) {
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
$i++;
}
return [];
}
// handle currency alias
if (! empty($currency)) {
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
} else {
$currency = Currency::all()->pluck('currency')->toArray();
}
// get rates
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last());
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
$datesOnly = array_keys($rates);
// loop through each date
$updates = [];
foreach ($period as $date) {
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
if (is_null($lookupDate)) {
continue;
}
// loop through each rate
foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) {
// add to updates
$updates[] = [
'currency' => $curr,
'date' => $date->toDateString(),
'rate' => $rate,
'updated_at' => now()->toDateTimeString(),
'created_at' => now()->toDateTimeString(),
];
}
}
// persist
self::chunkInsert($updates);
if (is_string($currency)) {
return collect($updates)
->whereBetween('date', [$start, $end ?? now()])
->where('currency', $currency)
->mapWithKeys(fn ($rate) => [
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
])
->toArray();
}
return [];
}
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
{
// if no dates, nothing to do...
if (empty($datesOnly)) {
return null;
}
$mutableDate = $date->copy();
$weekAgo = $date->copy()->subWeek();
$firstDate = Carbon::parse($datesOnly[0]);
// get rates or find closest valid rate (handles missing weekend rates)
while (! isset($rates[$mutableDate->toDateString()])) {
// prevent runaway infinite loops
if ($mutableDate->lessThan($weekAgo)) {
return null;
}
// is this the start of a range that falls on a weekend?
if ($mutableDate->lessThan($firstDate)) {
return $firstDate;
}
// try the day before then
$mutableDate = $mutableDate->subDay();
}
return $mutableDate;
}
public static function refreshCurrencyData($force = false): void
{
$currencies = Currency::all()->pluck('currency')->toArray();
$rates = Frankfurter::setBaseCurrency(config('investbrain.base_currency'))
->setSymbols($currencies)
->latest();
$updates = [];
foreach (Arr::get($rates, 'rates', []) as $currency => $rate) {
// update currency
$updates[] = [
'date' => now()->toDateString(),
'currency' => $currency,
'rate' => $rate,
];
}
// nothing to update
if (empty($updates)) {
return;
}
if ($force) {
// force overwrite existing rates
CurrencyRate::upsert($updates, ['currency', 'date'], ['rate']);
} else {
// only insert new rates
CurrencyRate::insertOrIgnore($updates);
}
}
public static function chunkInsert(array $updates): void
{
foreach (array_chunk($updates, 500) as $chunk) {
QueuedCurrencyRateInsertJob::dispatch($chunk);
}
}
protected static function getCurrencyAliasAdjustments(string $currency)
{
$adjustment = 1;
if (array_key_exists($currency, config('investbrain.currency_aliases', []))) {
$config = config('investbrain.currency_aliases.'.$currency);
$adjustment = $config['adjustment'];
$currency = $config['alias_of'];
}
return [$currency, $adjustment];
}
}
+120 -12
View File
@@ -1,14 +1,17 @@
<?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;
use Illuminate\Support\Facades\DB;
class DailyChange extends Model class DailyChange extends Model
{ {
use HasFactory, HasCompositePrimaryKey; use HasCompositePrimaryKey, HasFactory;
public $timestamps = false; public $timestamps = false;
@@ -20,10 +23,6 @@ class DailyChange extends Model
'portfolio_id', 'portfolio_id',
'date', 'date',
'total_market_value', 'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'notes', 'notes',
]; ];
@@ -31,28 +30,137 @@ class DailyChange extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
'total_market_value' => 'float',
'total_cost_basis' => 'float',
'total_market_gain' => 'float',
'realized_gain_dollars' => 'float',
'total_dividends_earned' => 'float',
]; ];
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio)
{ {
return $query->where('portfolio_id', $portfolio); return $query->where('daily_change.portfolio_id', $portfolio);
} }
public function scopeMyDailyChanges() public function scopeMyDailyChanges($query)
{ {
return $this->whereHas('portfolio', function ($query) { return $query->whereHas('portfolio', function ($query) {
$query->whereHas('users', function ($query) { $query->whereHas('users', function ($query) {
return $query->where('id', auth()->id()); return $query->where('id', auth()->id());
}); });
}); });
} }
public function scopeWithoutWishlists($query) { public function scopeWithoutWishlists($query)
{
return $query->whereHas('portfolio', function ($query) { return $query->whereHas('portfolio', function ($query) {
$query->where('portfolios.wishlist', 0); $query->where('portfolios.wishlist', 0);
}); });
} }
public function scopeWithDailyPerformance($query)
{
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
$dividendSub = DB::table('holdings')
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->join('transactions as tx', function ($join) {
$join->on('tx.symbol', '=', 'holdings.symbol')
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
->whereColumn('tx.date', '<=', 'dividends.date');
})
->select(['holdings.portfolio_id', 'dividends.date'])
->selectRaw("
((CASE WHEN tx.transaction_type = 'BUY'
THEN tx.quantity ELSE 0 END)
- (CASE WHEN tx.transaction_type = 'SELL'
THEN tx.quantity ELSE 0 END))
* SUM(
dividends.dividend_amount_base
* COALESCE(cr.rate, 1)
)
AS total_dividends_earned")
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
$transactionTotals = DB::table('transactions')
->select(['transactions.portfolio_id', 'transactions.date'])
->selectRaw("
SUM(
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
* transactions.quantity
* transactions.cost_basis_base
* COALESCE(cr.rate, 1)
) AS daily_cost_basis
")
->selectRaw("
SUM(
(CASE
WHEN transactions.transaction_type = 'SELL'
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
* transactions.quantity
* COALESCE(cr.rate, 1)
END)
) AS daily_realized_gains
")
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
->where('cr.currency', $currency);
})
->groupBy('transactions.portfolio_id', 'transactions.date');
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
->mergeBindings($transactionTotals)
->select(['portfolio_id', 'date'])
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
->groupBy('portfolio_id', 'date');
return $query
->select(['daily_change.date', 'daily_change.portfolio_id'])
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
AS total_market_gain')
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
->selectSub(function ($query) use ($dividendSub) {
$query->fromSub($dividendSub, 'd')
->selectRaw('SUM(d.total_dividends_earned)')
->whereColumn('d.date', '<=', 'daily_change.date')
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
}, 'total_dividends_earned')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
->where('cr.currency', $currency);
})
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
$join
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
->whereRaw('ccb.date <= daily_change.date');
})
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
->orderBy('daily_change.date');
}
public function scopeWithMultipleDailyPerformance($query)
{
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
->addBinding($query->getQuery()->getBindings(), 'join')
->select('date')
->selectRaw('SUM(total_market_value) AS total_market_value')
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
->selectRaw('SUM(total_market_gain) AS total_market_gain')
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
->groupBy('date');
}
public function portfolio() public function portfolio()
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
+111 -70
View File
@@ -1,20 +1,27 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Holding; use App\Actions\CopyToBaseCurrency;
use App\Models\MarketData; use App\Casts\BaseCurrency;
use App\Models\Transaction;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use App\Traits\HasMarketData;
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\HasMany;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;
use Illuminate\Support\Str;
class Dividend extends Model class Dividend extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -26,19 +33,33 @@ class Dividend extends Model
protected $hidden = []; protected $hidden = [];
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'date',
'last_date' => 'datetime', 'last_dividend_update' => 'date',
'dividend_amount' => 'float',
'dividend_amount_base' => BaseCurrency::class,
]; ];
public function marketData() { protected static function boot()
return $this->belongsTo(MarketData::class, 'symbol', 'symbol'); {
parent::boot();
static::saving(function ($dividend) {
$dividend = Pipeline::send($dividend)
->through([
CopyToBaseCurrency::class,
])
->then(fn (Dividend $dividend) => $dividend);
});
} }
public function holdings() { public function holdings(): HasMany
{
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() { public function transactions(): HasMany
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol'); return $this->hasMany(Transaction::class, 'symbol', 'symbol');
} }
@@ -49,26 +70,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;
}
// skip refresh if there's already recent data
if ($start_date->greaterThan($end_date)) {
return;
} }
// get some data // get some data
@@ -78,20 +102,32 @@ 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
foreach ($dividend_data as $index => $dividend){
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
// insert records $market_data = MarketData::getMarketData($symbol);
(new self)->insert($dividend_data->toArray());
$dividend_data
->chunk(10)
->each(function ($chunk) use ($market_data) {
// get historic conversion rates
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
// create mass insert
foreach ($chunk as $index => $dividend) {
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
}
// insert records
(new self)->insertOrIgnore($chunk->toArray());
});
// sync to holdings // sync to holdings
self::syncHoldings($symbol); self::syncHoldings($symbol);
// get market data
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
// re-invest dividends // re-invest dividends
self::reinvestDividends($dividend_data, $market_data); self::reinvestDividends($dividend_data, $market_data);
@@ -99,40 +135,44 @@ class Dividend extends Model
$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(string $symbol): void public static function syncHoldings(string $symbol): void
{ {
// 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', $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', 'dividends.dividend_amount_base');
// iterate through holdings and update $dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
->mergeBindings($subQuery->getQuery())
->where('total_received', '>', 0)
->get();
// iterate through holdings and update
Holding::where(['symbol' => $symbol]) 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 public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
@@ -142,21 +182,22 @@ class Dividend extends Model
'symbol' => $market_data->symbol, 'symbol' => $market_data->symbol,
'reinvest_dividends' => true, 'reinvest_dividends' => true,
]) ])
->get() ->get()
->each(function($holding) use ($dividend_data, $market_data) { ->each(function ($holding) use ($dividend_data, $market_data) {
foreach($dividend_data as $dividend) { foreach ($dividend_data as $dividend) {
Transaction::create([ Transaction::create([
'date' => $dividend['date'], 'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id, 'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol, 'symbol' => $holding->symbol,
'transaction_type' => "BUY", 'currency' => $holding->market_data->currency,
'reinvested_dividend' => true, 'transaction_type' => 'BUY',
'cost_basis' => 0, 'reinvested_dividend' => true,
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value, 'cost_basis' => 0,
]); 'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
} ]);
}); }
});
} }
} }
+418 -151
View File
@@ -1,21 +1,21 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Split; use App\Traits\HasMarketData;
use App\Models\AiChat;
use App\Models\Dividend;
use App\Models\Portfolio;
use App\Models\MarketData;
use App\Models\Transaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use 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\Collection;
use Illuminate\Support\Facades\DB;
class Holding extends Model class Holding extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -27,36 +27,34 @@ class Holding extends Model
'realized_gain_dollars', 'realized_gain_dollars',
'dividends_earned', 'dividends_earned',
'splits_synced_at', 'splits_synced_at',
'reinvest_dividends' 'reinvest_dividends',
]; ];
protected $casts = [ protected $casts = [
'reinvest_dividends' => 'boolean',
'splits_synced_at' => 'datetime', 'splits_synced_at' => 'datetime',
'first_transaction_date' => 'datetime', 'first_transaction_date' => 'datetime',
'reinvest_dividends' => 'boolean' 'quantity' => 'float',
'average_cost_basis' => 'float',
'total_cost_basis' => 'float',
'realized_gain_dollars' => 'float',
'dividends_earned' => 'float',
'total_market_gain_dollars' => 'float',
'market_gain_dollars' => 'float',
'total_market_value' => 'float',
'total_dividends_earned' => 'float',
'market_data_market_value' => 'float',
'market_data_fifty_two_week_low' => 'float',
'market_data_fifty_two_week_high' => 'float',
'market_gain_percent' => 'float',
]; ];
protected $attributes = [
'realized_gain_dollars' => 0,
'dividends_earned' => 0,
];
/**
* Market data for holding
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/** /**
* Related transactions for holding * Related transactions for holding
* *
* @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');
} }
@@ -66,49 +64,80 @@ 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', 'dividends.dividend_amount_base'])
->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 date(dividends.date) >= 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 date(dividends.date) >= 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")
->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 date(transactions.date) <= date(dividends.date) AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END THEN transactions.quantity ELSE 0 END
- 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 date(transactions.date) <= date(dividends.date) AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END) THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount * dividends.dividend_amount
) AS total_received") ) AS total_received")
->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'
->having('total_received', '>', 0); AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount_base
) AS total_received_base")
->join('transactions', 'transactions.symbol', 'dividends.symbol')
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
->orderBy('dividends.date', 'DESC')
->where('dividends.date', '>=', function ($query) {
$query->selectRaw('min(transactions.date)')
->from('transactions')
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
->whereRaw("transactions.symbol = '$this->symbol'");
})
->havingRaw("SUM(
(CASE
WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND transactions.date <= dividends.date
THEN transactions.quantity
ELSE 0
END)
-
(CASE
WHEN transaction_type = 'SELL'
AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id'
AND transactions.date <= dividends.date
THEN transactions.quantity
ELSE 0
END)
) * dividends.dividend_amount_base > 0");
} }
/** /**
@@ -116,7 +145,7 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function portfolio() public function portfolio()
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
} }
@@ -126,7 +155,7 @@ 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');
@@ -145,18 +174,22 @@ class Holding extends Model
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', 'market_value_base')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'fifty_two_week_high')
->join('market_data', 'holdings.symbol', 'market_data.symbol'); ->withAggregate('market_data', 'updated_at')
->join('market_data', 'holdings.symbol', 'market_data.symbol');
} }
/**
* Calculate performance for holding in its local currency
*/
public function scopeWithPerformance($query) 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) * 100, 0) AS market_gain_percent'); ->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
} }
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio)
@@ -169,68 +202,258 @@ 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->whereHas('portfolio', function ($query) { return $query->whereHas('portfolio', function ($query) {
$query->where('portfolios.wishlist', 0); $query->where('portfolios.wishlist', 0);
}); });
} }
public function scopeMyHoldings($query, $userId = null) public function scopeMyHoldings($query, $userId = null)
{ {
return $query->whereHas('portfolio', function($query) use ($userId) { return $query->whereHas('portfolio', function ($query) use ($userId) {
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id); $query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
}); });
} }
public function scopeWithPortfolioMetrics($query) /**
* Scope which returns collection of performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
{ {
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned') $result = $query->withPortfolioMetrics($currency)->get();
->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') return collect([
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis') 'total_cost_basis' => $result->sum('total_cost_basis'),
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars') 'total_market_value' => $result->sum('total_market_value'),
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent') 'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol'); 'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
'total_dividends_earned' => $result->sum('total_dividends_earned'),
]);
}
/**
* Scope to collect performance metrics for holdings
*
* @param string $currency Allows casting to specified currency
*/
public function scopeWithPortfolioMetrics($query, $currency = null): mixed
{
$currency = $currency ?? auth()->user()->getCurrency();
$cost_basis_sub = DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select([
'transactions.id',
'transactions.symbol',
'transactions.portfolio_id',
])
->leftJoinSub(
DB::table('transactions')
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'transactions.date')
->where('cr.currency', '=', $currency);
})
->select([
'transactions.symbol',
'transactions.portfolio_id',
'transactions.quantity',
'transactions.cost_basis_base',
'transactions.date',
])
->selectRaw("
(CASE
WHEN
transactions.transaction_type = 'BUY'
OR SUM(transactions.cost_basis_base) = 0
THEN
COALESCE(cr.rate, 1)
ELSE (
SELECT
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
/ SUM(buy.cost_basis_base)
FROM transactions as buy
LEFT JOIN currency_rates as cr2
ON cr2.date = buy.date
AND cr2.currency = '{$currency}'
WHERE buy.symbol = transactions.symbol
AND buy.portfolio_id = transactions.portfolio_id
AND buy.transaction_type = 'BUY'
AND buy.date <= transactions.date
) END)
AS rate")
->groupBy([
'transactions.id',
'transactions.symbol',
'transactions.date',
'transactions.portfolio_id',
'transactions.transaction_type',
'transactions.cost_basis_base',
'transactions.quantity',
'cr.rate',
]),
'cost_basis_display',
function ($join) {
$join
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
->on(
'transactions.portfolio_id',
'=',
'cost_basis_display.portfolio_id'
)
->on('transactions.date', '=', 'cost_basis_display.date');
}
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
)
->selectRaw(
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
)
->groupBy([
'transactions.id',
'transactions.symbol',
'transactions.portfolio_id',
'transactions.cost_basis_base',
'transactions.quantity',
'cost_basis_display.rate',
'cr.rate',
]);
$dividends_sub = DB::table('dividends')
->join('transactions as tx', function ($join) {
$join
->on('tx.symbol', '=', 'dividends.symbol')
->on('tx.date', '<=', 'dividends.date');
})
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join
->on('cr.date', '=', 'dividends.date')
->where('cr.currency', '=', $currency);
})
->select(['dividends.symbol', 'tx.portfolio_id'])
->selectRaw(
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
)
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
return $query->select([
'holdings.symbol',
'holdings.portfolio_id',
'dividends_display.total_dividends_earned',
])
->groupBy([
'holdings.symbol',
'holdings.quantity',
'holdings.portfolio_id',
'cr.rate',
'dividends_display.total_dividends_earned',
'market_data.market_value_base',
])
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
$join->where('cr.currency', '=', $currency);
if (config('database.default') === 'sqlite') {
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [
now()->toDateString(),
]);
} else {
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
}
})
->leftJoin('market_data', function ($join) {
$join->on('market_data.symbol', '=', 'holdings.symbol');
})
->selectRaw('
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
AS total_market_value
')
->selectRaw('
SUM(transactions_display.realized_gain_dollars)
AS realized_gain_dollars
')
->selectRaw('
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
* holdings.quantity
AS total_cost_basis
')
->selectRaw('
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
* holdings.quantity
AS total_market_gain_dollars
')
->leftJoinSub($cost_basis_sub, 'transactions_display',
function ($join) {
$join
->on('holdings.symbol', '=', 'transactions_display.symbol')
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
}
)
->leftJoinSub($dividends_sub, 'dividends_display',
function ($join) {
$join->on('holdings.symbol', '=', 'dividends_display.symbol') // todo: this isnt limiting to port ids
->on('holdings.portfolio_id', '=', 'dividends_display.portfolio_id');
}
);
} }
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, 'transactions.symbol' => $this->symbol,
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`') ])->selectRaw("SUM(CASE WHEN transaction_type = '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 `total_cost_basis`') ->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`') ->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
->first(); ->first();
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3); // delete holding if no transactions
if (empty($query->qty_purchases + $query->qty_sales)) {
$this->delete();
return;
}
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
$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->total_cost_basis / $query->qty_purchases : 0;
: 0;
// 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->qty_purchases > 0 && $query->total_sale_price > 0 'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases)) 'dividends_earned' => $this->dividends->sum('total_received'),
: 0,
'dividends_earned' => $this->dividends->sum('total_received')
]); ]);
$this->save(); $this->save();
} }
public function qtyOwned(\Illuminate\Support\Carbon $date = null) public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
{ {
if ($date == null) $date = now(); if ($date == null) {
$date = now();
}
$transactions = $this->transactions->where('date', '<=', $date); $transactions = $this->transactions->where('date', '<=', $date);
@@ -241,78 +464,122 @@ class Holding extends Model
return $purchases - $sales; return $purchases - $sales;
} }
/**
* Method that enables calculating daily performance for a given holding
*
* @return void
*/
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();
}
$date_interval = "DATE_ADD(date, INTERVAL 1 DAY)"; if ($end_date == null) {
$end_date = now();
if (config('database.default') === 'sqlite') {
$date_interval = "date(date, '+1 day')";
} else {
DB::statement('SET cte_max_recursion_depth=1000000;');
} }
return DB::table(DB::raw("( // MySQL default interval
WITH RECURSIVE date_series AS ( $date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
SELECT '{$start_date->format('Y-m-d')}' AS date $castNumberType = 'decimal';
UNION ALL
SELECT $date_interval // Use SQLite interval grammar
FROM date_series if (config('database.default') === 'sqlite') {
WHERE date < '{$end_date->format('Y-m-d')}'
) $date_interval = "date(date, '+1 day')";
SELECT date_series.date }
// Default CTE time series query (for MySQL and SQLite)
$timeSeriesQuery = DB::table(DB::raw("(
WITH RECURSIVE date_series AS (
SELECT '{$start_date->toDateString()}' AS date
UNION ALL
SELECT $date_interval
FROM date_series FROM date_series
) as date_series") WHERE date < '{$end_date->toDateString()}'
) )
->select([ SELECT date_series.date
'date_series.date', FROM date_series
DB::raw(" ) as date_series"));
ROUND(
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) - // PGSql time series query
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned` if (config('database.default') === 'pgsql') {
"),
DB::raw(" $timeSeriesQuery = DB::table(DB::raw("
COALESCE(CASE generate_series(
WHEN ( date '{$start_date->toDateString()}',
ROUND( date '{$end_date->toDateString()}',
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) - interval '1 day'
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) ) as date_series"));
) = 0 THEN 0
ELSE SUM(CASE $castNumberType = 'numeric';
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis }
ELSE 0
END) // Set MySQL-like query CTE max iterations
END, 0) AS cost_basis if (config('database.default') === 'mysql') {
"),
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`") // MySQL default
]) $max_recursion_var_name = 'cte_max_recursion_depth';
->leftJoin('transactions', function ($join) {
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') // Determine if running MySQL or MariaDB
->where('transactions.symbol', '=', $this->symbol) $versionString = Arr::get(
->where('transactions.portfolio_id', '=', $this->portfolio_id); DB::select('SELECT VERSION() as version;'),
}) '0', new \stdClass
->groupBy('date_series.date') )->version;
->orderBy('date_series.date') if (stripos($versionString, 'MariaDB') !== false) {
->get() $max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
->keyBy('date'); }
DB::statement("SET $max_recursion_var_name=1000000;");
}
// Extracted query for counting QTY owned
$quantityQuery = "ROUND(CAST(COALESCE(
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
0
) AS {$castNumberType}), 3)";
return $timeSeriesQuery
->select([
'date_series.date',
DB::raw("
{$quantityQuery} AS owned
"),
DB::raw("
CASE
WHEN ({$quantityQuery}) = 0 THEN 0
ELSE SUM(CASE
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
ELSE 0
END)
END AS cost_basis
"),
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
])
->leftJoin('transactions', function ($join) {
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
->where('transactions.symbol', '=', $this->symbol)
->where('transactions.portfolio_id', '=', $this->portfolio_id);
})
->groupBy('date_series.date')
->orderBy('date_series.date')
->get()
->keyBy('date');
} }
public function getFormattedTransactions() public function getFormattedTransactions()
{ {
$formattedTransactions = ''; $formattedTransactions = '';
foreach($this->transactions->sortByDesc('date') as $transaction) { foreach ($this->transactions->sortByDesc('date') as $transaction) {
$formattedTransactions .= " * ".$transaction->date->format('Y-m-d') $formattedTransactions .= ' * '.$transaction->date->toDateString()
." ". $transaction->transaction_type .' '.$transaction->transaction_type
." ". $transaction->quantity .' '.$transaction->quantity
." @ ". $transaction->cost_basis .' @ '.$transaction->cost_basis
." each \n\n"; ." each \n\n";
} }
return $formattedTransactions; return $formattedTransactions;
} }
} }
+39 -11
View File
@@ -1,23 +1,32 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use App\Actions\CopyToBaseCurrency;
use App\Casts\BaseCurrency;
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;
use Illuminate\Support\Facades\Pipeline;
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 = [
'symbol', 'symbol',
'name', 'name',
'currency',
'market_value', 'market_value',
'market_value_base',
'fifty_two_week_high', 'fifty_two_week_high',
'fifty_two_week_low', 'fifty_two_week_low',
'forward_pe', 'forward_pe',
@@ -25,22 +34,41 @@ class MarketData extends Model
'market_cap', 'market_cap',
'book_value', 'book_value',
'last_dividend_date', 'last_dividend_date',
'dividend_yield' 'last_dividend_amount',
'dividend_yield',
'meta_data',
]; ];
protected $casts = [ protected $casts = [
'last_dividend_date' => 'datetime',
'market_value' => 'float', 'market_value' => 'float',
'market_value_base' => BaseCurrency::class,
'fifty_two_week_high' => 'float', 'fifty_two_week_high' => 'float',
'fifty_two_week_low' => 'float', 'fifty_two_week_low' => 'float',
'forward_pe' => 'float', 'forward_pe' => 'float',
'trailing_pe' => 'float', 'trailing_pe' => 'float',
'market_cap' => 'float', 'market_cap' => 'integer',
'book_value' => 'float', 'book_value' => 'float',
'dividend_yield' => 'float' 'last_dividend_date' => 'datetime',
'last_dividend_amount' => 'float',
'dividend_yield' => 'float',
'meta_data' => 'json',
]; ];
public function holdings() protected static function boot()
{
parent::boot();
static::saving(function ($market_data) {
$market_data = Pipeline::send($market_data)
->through([
CopyToBaseCurrency::class,
])
->then(fn (MarketData $market_data) => $market_data);
});
}
public function holdings()
{ {
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
@@ -50,20 +78,20 @@ class MarketData extends Model
return $query->where('symbol', $symbol); return $query->where('symbol', $symbol);
} }
public static function getMarketData($symbol, $force = false) public static function getMarketData($symbol, $force = false): self
{ {
$market_data = self::firstOrNew([ $market_data = self::firstOrNew([
'symbol' => $symbol 'symbol' => $symbol,
]); ]);
// check if new or stale // check if new or stale
if ( if (
$force $force
|| !$market_data->exists || ! $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);
@@ -76,4 +104,4 @@ class MarketData extends Model
return $market_data; return $market_data;
} }
} }
+106 -71
View File
@@ -1,16 +1,19 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\AiChat; use App\Interfaces\MarketData\MarketDataInterface;
use App\Notifications\InvitedOnboardingNotification;
use Carbon\CarbonPeriod; use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str;
use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Portfolio extends Model class Portfolio extends Model
{ {
@@ -28,7 +31,7 @@ class Portfolio extends Model
protected static function boot() protected static function boot()
{ {
parent::boot(); parent::boot();
static::saved(function ($portfolio) { static::saved(function ($portfolio) {
self::ensurePortfolioHasOwner($portfolio); self::ensurePortfolioHasOwner($portfolio);
@@ -38,7 +41,7 @@ class Portfolio extends Model
protected $hidden = []; protected $hidden = [];
protected $casts = [ protected $casts = [
'wishlist' => 'boolean' 'wishlist' => 'boolean',
]; ];
protected $with = ['users', 'transactions']; protected $with = ['users', 'transactions'];
@@ -51,8 +54,8 @@ class Portfolio extends Model
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()
@@ -75,25 +78,25 @@ class Portfolio extends Model
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id); return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
} }
public function scopeMyPortfolios() 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 scopeFullAccess($query, $user_id = null) public function scopeFullAccess($query, $user_id = null)
{ {
return $query->whereHas('users', function ($query) use ($user_id) { return $query->whereHas('users', function ($query) use ($user_id) {
$query->where('user_id', $user_id ?? auth()->user()->id) $query->where('user_id', $user_id ?? auth()->user()->id)
->where(function ($query) { ->where(function ($query) {
$query->where('full_access', true) $query->where('full_access', true)
->orWhere('owner', true); ->orWhere('owner', true);
}); });
}); });
} }
public function scopeWithoutWishlists() public function scopeWithoutWishlists()
{ {
return $this->where(['wishlist' => false]); return $this->where(['wishlist' => false]);
} }
@@ -101,7 +104,7 @@ class Portfolio extends Model
public function setOwnerIdAttribute($value) public function setOwnerIdAttribute($value)
{ {
// enable queued jobs to create portfolios with owners // enable queued jobs to create portfolios with owners
if (!auth()->user()?->id && !$this->owner_id) { if (! auth()->user()?->id && ! $this->owner_id) {
static::$owner_id = $value; static::$owner_id = $value;
} }
} }
@@ -113,124 +116,122 @@ class Portfolio extends Model
public function getOwnerAttribute() public function getOwnerAttribute()
{ {
if (!$this->relationLoaded('user')) { if (! $this->relationLoaded('user')) {
$this->load('users'); $this->load('users');
} }
return $this->users->where('pivot.owner', true)->first(); return $this->users->where('pivot.owner', true)->first();
} }
public static function ensurePortfolioHasOwner(self $portfolio) public static function ensurePortfolioHasOwner(self $portfolio)
{ {
// make sure we don't remove owner access // make sure we don't remove owner access
if (!$portfolio->owner_id) { if (! $portfolio->owner_id) {
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true]; $owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
// save // save
$portfolio->users()->sync($owner); $portfolio->users()->sync($owner);
static::$owner_id = null;
} }
} }
/**
* Writes daily change history for a portfolio to the database
*/
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();
$total_performance = []; $total_performance = [];
$holdings->each(function($holding) use (&$total_performance, $dividends) { // get unique currencies for holdings
$currency_rates = [];
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
}
$holdings->each(function ($holding) use (&$total_performance, $currency_rates) {
$period = CarbonPeriod::create( $period = CarbonPeriod::create(
$holding->first_transaction_date, $holding->first_transaction_date,
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day'))) now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
? now()->subDay() ? now()->subDay()
: now() : now()
); );
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
$daily_performance = $holding->dailyPerformance($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');
});
$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());
$dividends_earned = 0;
$holding_performance = []; $holding_performance = [];
foreach($period as $date) { foreach ($period as $date) {
$date = $date->format('Y-m-d'); $date = $date->toDateString();
$close = $this->getMostRecentCloseData($all_history, $date); $close = $this->getMostRecentCloseData($all_history, $date);
$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);
if (Carbon::parse($date)->isWeekday()) { if (Carbon::parse($date)->isWeekday()) {
$holding_performance[$date] = [ $holding_performance[$date] = [
'date' => $date, 'date' => $date,
'portfolio_id' => $this->id, 'portfolio_id' => $this->id,
'total_market_value' => $total_market_value, 'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
'realized_gains' => $daily_performance->get($date)->realized_gains,
'total_dividends_earned' => $dividends_earned
]; ];
} }
} }
foreach ($holding_performance as $date => $performance) { foreach ($holding_performance as $date => $performance) {
if (Arr::get($total_performance, $date) == null) { if (Arr::get($total_performance, $date) == null) {
$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_gain'] += $performance['total_gain'];
$total_performance[$date]['realized_gains'] += $performance['realized_gains'];
$total_performance[$date]['total_dividends_earned'] += $performance['total_dividends_earned'];
} }
} }
}); });
if (!empty($total_performance)) { if (! empty($total_performance)) {
DB::transaction(function () use ($total_performance) { DB::transaction(function () use ($total_performance) {
// delete old history
$firstDate = array_keys($total_performance)[0];
$this->daily_change()->where('date', '<', $firstDate)->delete();
// upsert new history
$this->daily_change()->upsert( $this->daily_change()->upsert(
$total_performance, $total_performance,
['date', 'portfolio_id'], ['date', 'portfolio_id'],
[ [
'total_market_value', 'total_market_value',
'total_cost_basis',
'total_gain',
'realized_gains',
'total_dividends_earned'
] ]
); );
}); });
} }
cache()->forget('graph-YTD-'.$this->id);
cache()->forget('graph-YTD-'.request()->user()?->id);
} }
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5) protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
{ {
$close = Arr::get($history, "$date.close", 0); $close = Arr::get($history, "$date.close", 0);
if (!$close && $i < $max_attempts) { if (! $close && $i < $max_attempts) {
$i++; $i++;
$date = Carbon::parse($date)->subDay()->format('Y-m-d'); $date = Carbon::parse($date)->subDay()->toDateString();
return $this->getMostRecentCloseData($history, $date, $i); return $this->getMostRecentCloseData($history, $date, $i);
} }
@@ -241,16 +242,50 @@ class Portfolio extends Model
public function getFormattedHoldings() public function getFormattedHoldings()
{ {
$formattedHoldings = ''; $formattedHoldings = '';
foreach($this->holdings as $holding) { foreach ($this->holdings as $holding) {
$formattedHoldings .= " * Holding of ".$holding->market_data->name." (".$holding->symbol.")" $formattedHoldings .= ' * Holding of '.$holding->market_data->name.' ('.$holding->symbol.')'
."; with ". ($holding->quantity > 0 ? $holding->quantity : 'ZERO') . " shares" .'; with '.($holding->quantity > 0 ? $holding->quantity : 'ZERO').' shares'
."; avg cost basis ". $holding->average_cost_basis .'; avg cost basis '.$holding->average_cost_basis
."; curr market value ". $holding->market_data->market_value .'; curr market value '.$holding->market_data->market_value
."; unrealized gains ". $holding->market_gain_dollars .'; unrealized gains '.$holding->market_gain_dollars
."; realized gains ". $holding->realized_gain_dollars .'; realized gains '.$holding->realized_gain_dollars
."; dividends earned ". $holding->dividends_earned .'; dividends earned '.$holding->dividends_earned
."\n\n"; ."\n\n";
} }
return $formattedHoldings; return $formattedHoldings;
} }
/**
* Share a portfolio with a user
*/
public function share(string $email, bool $fullAccess = false): void
{
$user = User::firstOrCreate([
'email' => $email,
], [
'name' => Str::title(Str::before($email, '@')),
]);
$permissions[$user->id] = [
'full_access' => $fullAccess,
];
$sync = $this->users()->syncWithoutDetaching($permissions);
if (! empty($sync['attached'])) {
foreach ($sync['attached'] as $newUserId) {
User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
}
}
}
/**
* Un-share a portfolio
*/
public function unShare(string $userId): void
{
$this->users()->detach($userId);
}
} }
+44 -39
View File
@@ -1,18 +1,22 @@
<?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 App\Traits\HasMarketData;
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\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Split extends Model class Split extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -28,22 +32,23 @@ class Split extends Model
'last_date' => 'datetime', 'last_date' => 'datetime',
]; ];
public function holdings() { public function holdings(): HasMany
{
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() { public function transactions(): HasMany
{
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 +63,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 +76,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)->insertOrIgnore($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 +89,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->toDateString())
->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 +133,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(),
]); ]);
} }
} }
+58 -68
View File
@@ -1,17 +1,28 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\MarketData; use App\Actions\ConvertToMarketDataCurrency;
use Illuminate\Support\Arr; use App\Actions\CopyToBaseCurrency;
use Illuminate\Database\Eloquent\Model; use App\Actions\EnsureCostBasisAddedToSale;
use App\Actions\EnsureDailyChangeIsSynced;
use App\Casts\BaseCurrency;
use App\Traits\HasMarketData;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\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\Support\Arr;
use Illuminate\Support\Facades\Pipeline;
class Transaction extends Model class Transaction extends Model
{ {
use HasFactory; use HasFactory;
use HasMarketData;
use HasUuids; use HasUuids;
protected $fillable = [ protected $fillable = [
@@ -19,11 +30,12 @@ class Transaction extends Model
'date', 'date',
'portfolio_id', 'portfolio_id',
'transaction_type', 'transaction_type',
'currency',
'quantity', 'quantity',
'cost_basis', 'cost_basis',
'sale_price', 'sale_price',
'split', 'split',
'reinvested_dividend' 'reinvested_dividend',
]; ];
protected $hidden = []; protected $hidden = [];
@@ -31,7 +43,12 @@ class Transaction extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
'split' => 'boolean', 'split' => 'boolean',
'reinvested_dividend' => 'boolean' 'reinvested_dividend' => 'boolean',
'quantity' => 'float',
'cost_basis' => 'float',
'sale_price' => 'float',
'cost_basis_base' => BaseCurrency::class,
'sale_price_base' => BaseCurrency::class,
]; ];
protected static function boot() protected static function boot()
@@ -40,26 +57,33 @@ class Transaction extends Model
static::saving(function ($transaction) { static::saving(function ($transaction) {
if ($transaction->transaction_type == 'SELL') { $transaction = Pipeline::send($transaction)
->through([
$transaction->ensureCostBasisIsAddedToSale(); ConvertToMarketDataCurrency::class,
} EnsureCostBasisAddedToSale::class,
CopyToBaseCurrency::class,
])
->then(fn (Transaction $transaction) => $transaction);
}); });
static::saved(function ($transaction) { static::saved(function ($transaction) {
$transaction->syncToHolding(); $transaction->syncToHolding();
$transaction->refreshMarketData(); $transaction = Pipeline::send($transaction)
->through([
EnsureDailyChangeIsSynced::class,
])
->then(fn (Transaction $transaction) => $transaction);
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id); cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
}); });
static::deleted(function ($transaction) { static::deleted(function ($transaction) {
$transaction->syncToHolding(); $transaction->syncToHolding();
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id); cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
}); });
} }
@@ -73,62 +97,53 @@ class Transaction extends Model
); );
} }
/**
* Related market data for transaction
*
* @return void
*/
public function market_data()
{
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
}
/** /**
* Related portfolio * Related portfolio
* *
* @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', 'currency')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'fifty_two_week_high')
->join('market_data', 'transactions.symbol', 'market_data.symbol'); ->withAggregate('market_data', 'updated_at')
->join('market_data', 'transactions.symbol', 'market_data.symbol');
} }
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio): Builder
{ {
return $query->where('portfolio_id', $portfolio); 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) {
@@ -137,36 +152,11 @@ class Transaction extends Model
}); });
} }
public function refreshMarketData()
{
return MarketData::getMarketData($this->attributes['symbol']);
}
/**
* Writes average cost basis to a sale transaction
*
* @return Transaction
*/
public function ensureCostBasisIsAddedToSale()
{
$average_cost_basis = Transaction::where([
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $this->date)
->average('cost_basis');
$this->cost_basis = $average_cost_basis ?? 0;
return $this;
}
/** /**
* Syncs the holding related to this transaction * 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')) {
@@ -181,14 +171,14 @@ 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,
'quantity' => $this->quantity, 'quantity' => $this->quantity,
'average_cost_basis' => $this->cost_basis, 'average_cost_basis' => $this->cost_basis_base,
'total_cost_basis' => $this->quantity * $this->cost_basis, 'total_cost_basis' => $this->quantity * $this->cost_basis_base,
'splits_synced_at' => now(), 'splits_synced_at' => now(),
])->syncTransactionsAndDividends(); ])->syncTransactionsAndDividends();
} }
} }
+40 -12
View File
@@ -1,34 +1,38 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\HasConnectedAccounts; use App\Traits\HasConnectedAccounts;
use Laravel\Sanctum\HasApiTokens; use App\Traits\HasProfilePhoto;
use Laravel\Jetstream\HasProfilePhoto; use Illuminate\Contracts\Auth\MustVerifyEmail;
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\Contracts\Auth\MustVerifyEmail; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
class User extends Authenticatable implements MustVerifyEmail 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;
use HasConnectedAccounts;
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
'password', 'password',
'options',
]; ];
protected $hidden = [ protected $hidden = [
@@ -48,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'admin' => 'boolean',
'options' => 'json',
]; ];
} }
@@ -65,7 +71,7 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class]) return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
->withMarketData() ->withMarketData()
->withPerformance(); ->withPerformance();
} }
public function transactions(): HasManyDeep public function transactions(): HasManyDeep
@@ -78,6 +84,28 @@ class User extends Authenticatable implements MustVerifyEmail
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');
}
public function getCurrency(): string
{
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
}
public function getLocale(): string
{
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
}
public function setOption(mixed $key, ?string $value = null): self
{
$options = is_array($key) ? $key : [$key => $value];
$this->options = array_merge($this->options ?? [], $options);
return $this;
} }
} }
+10 -8
View File
@@ -1,11 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Notifications; namespace App\Notifications;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ImportFailedNotification extends Notification implements ShouldQueue class ImportFailedNotification extends Notification implements ShouldQueue
{ {
@@ -16,7 +18,7 @@ class ImportFailedNotification extends Notification implements ShouldQueue
*/ */
public function __construct( public function __construct(
public string $errorMessage public string $errorMessage
) { } ) {}
/** /**
* Get the notification's delivery channels. * Get the notification's delivery channels.
@@ -34,12 +36,12 @@ class ImportFailedNotification extends Notification implements ShouldQueue
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
{ {
return (new MailMessage) return (new MailMessage)
->greeting('Oh no!') ->greeting('Oh no!')
->subject("Your Investbrain import failed!") ->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.") ->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')) ->action('Try again?', route('import-export'))
->line("**Technical details:**") ->line('**Technical details:**')
->line($this->errorMessage); ->line($this->errorMessage);
} }
/** /**
@@ -1,13 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Notifications; namespace App\Notifications;
use App\Models\User;
use App\Models\Portfolio;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ImportSucceededNotification extends Notification implements ShouldQueue class ImportSucceededNotification extends Notification implements ShouldQueue
{ {
@@ -16,7 +16,7 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
/** /**
* Create a new notification instance. * Create a new notification instance.
*/ */
public function __construct() { } public function __construct() {}
/** /**
* Get the notification's delivery channels. * Get the notification's delivery channels.
@@ -34,10 +34,10 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
{ {
return (new MailMessage) return (new MailMessage)
->greeting('Woot! 🎉') ->greeting('Woot! 🎉')
->subject("Your Investbrain import was successful!") ->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.") ->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')); ->action('Get Started', route('dashboard'));
} }
/** /**
@@ -1,13 +1,15 @@
<?php <?php
declare(strict_types=1);
namespace App\Notifications; namespace App\Notifications;
use App\Models\User;
use App\Models\Portfolio; use App\Models\Portfolio;
use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvitedOnboardingNotification extends Notification implements ShouldQueue class InvitedOnboardingNotification extends Notification implements ShouldQueue
{ {
@@ -19,7 +21,7 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
public function __construct( public function __construct(
public Portfolio $portfolio, public Portfolio $portfolio,
public User $sender, public User $sender,
) { } ) {}
/** /**
* Get the notification's delivery channels. * Get the notification's delivery channels.
@@ -40,14 +42,14 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90)); $url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
return (new MailMessage) return (new MailMessage)
->replyTo($this->sender->email, $this->sender->name) ->replyTo($this->sender->email, $this->sender->name)
->greeting('Hey there! 👋') ->greeting('Hey there! 👋')
->subject("You've been invited to {$this->portfolio->title} on Investbrain!") ->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("{$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}!") ->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) ->action('Get Started', $url)
->line("If you have any questions, you can reply to this email.") ->line('If you have any questions, you can reply to this email.')
->salutation("See you there,\n". e($this->sender->name)); ->salutation("See you there,\n".e($this->sender->name));
} }
/** /**
@@ -1,12 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Notifications; namespace App\Notifications;
use Illuminate\Bus\Queueable;
use App\Models\ConnectedAccount; use App\Models\ConnectedAccount;
use Illuminate\Notifications\Notification; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
{ {
@@ -17,7 +19,7 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
*/ */
public function __construct( public function __construct(
public string $connected_account_id public string $connected_account_id
) { } ) {}
/** /**
* Get the notification's delivery channels. * Get the notification's delivery channels.
@@ -40,11 +42,11 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7)); $url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
return (new MailMessage) return (new MailMessage)
->greeting('Welcome back!') ->greeting('Welcome back!')
->subject("Connect your $provider account with Investbrain") ->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:") ->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) ->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."); ->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.");
} }
/** /**
+4 -12
View File
@@ -1,26 +1,21 @@
<?php <?php
declare(strict_types=1);
namespace App\Policies; namespace App\Policies;
use App\Models\User;
use App\Models\Portfolio; use App\Models\Portfolio;
use App\Models\User;
class PortfolioPolicy class PortfolioPolicy
{ {
/**
*
*/
public function readOnly(User $user, Portfolio $portfolio) public function readOnly(User $user, Portfolio $portfolio)
{ {
$pivot = $portfolio->users()->where('user_id', $user->id)->first(); $pivot = $portfolio->users()->where('user_id', $user->id)->first();
return !!$pivot; return (bool) $pivot;
} }
/**
*
*/
public function fullAccess(User $user, Portfolio $portfolio) public function fullAccess(User $user, Portfolio $portfolio)
{ {
$pivot = $portfolio->users()->where('user_id', $user->id)->first(); $pivot = $portfolio->users()->where('user_id', $user->id)->first();
@@ -28,9 +23,6 @@ class PortfolioPolicy
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner); return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
} }
/**
*
*/
public function owner(User $user, Portfolio $portfolio) public function owner(User $user, Portfolio $portfolio)
{ {
$pivot = $portfolio->users()->where('user_id', $user->id)->first(); $pivot = $portfolio->users()->where('user_id', $user->id)->first();
+30 -1
View File
@@ -1,8 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use NumberFormatter;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -22,6 +28,29 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// JsonResource::withoutWrapping();
Arr::macro('skipEmptyValues', function (array $array) {
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
$result = [];
if (! empty($value)) {
$result[$key] = $value;
}
return $result;
});
});
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
$currency = $currency ?? Number::defaultCurrency();
$locale = $locale ?? Number::defaultLocale();
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
});
} }
} }

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