Compare commits

...

358 Commits

Author SHA1 Message Date
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
hackerESQ ae22bb2e81 Merge pull request #5 from investbrainapp/dev
feat:improved LLM follow up questions prompt (and more)
2024-11-06 20:53:28 -06:00
hackerESQ f2e1211661 chore:remove welcome page 2024-11-06 20:48:38 -06:00
hackerESQ 489bbbbec6 fix:language trans for disclaimer 2024-11-06 16:08:16 -06:00
hackerESQ d992a359a6 chore:add packages to ignore file 2024-11-06 15:14:33 -06:00
hackerESQ c3e5d216ab fix:trim input strings including symbols 2024-11-06 10:49:12 -06:00
hackerESQ fee9cda5ba feat:adds admin field 2024-11-05 15:39:29 -06:00
hackerESQ 0c3a851e7d feat:add admin column for users 2024-11-05 15:36:27 -06:00
hackerESQ 56ceb92c2b fix:refined follow up prompt 2024-11-03 16:13:24 -06:00
hackerESQ fb96792821 Merge pull request #4 from investbrainapp/dev
fix:scope chat history for each user
2024-11-03 08:49:42 -06:00
hackerESQ b512500c9c Merge branch 'dev' of https://github.com/investbrainapp/investbrain into dev 2024-11-03 08:41:17 -06:00
hackerESQ b97a41f7ad fix:scope chat history for each user 2024-11-03 08:41:14 -06:00
hackerESQ 2706ed7162 Merge pull request #3 from investbrainapp/main
sync from main
2024-11-02 21:04:39 -05:00
hackerESQ 2494160c96 Merge pull request #2 from investbrainapp/dev
fix:make sure splits synced is updated when importing
2024-11-02 21:03:58 -05:00
hackerESQ dec253d860 fix:make sure splits synced is updated when importing 2024-11-02 12:35:23 -05:00
hackerESQ df9d863abb Merge pull request #1 from investbrainapp/dev
feat: Improved ai chat
2024-11-01 23:21:34 -05:00
hackerESQ 73dd885741 feat:adds more robust suggested follow up prompts
ensures the ai agent has access to the current date
2024-11-01 23:18:22 -05:00
hackerESQ 9cd6a37b05 fix:make chat window more mobile friendly 2024-11-01 22:13:40 -05:00
hackerESQ dfdb2af59f fix:steal focus for chat window 2024-11-01 14:26:11 -05:00
hackerESQ 772e868a59 fix:better markdown formatting 2024-11-01 14:25:50 -05:00
hackerESQ 748589226e docs:spelling is hard 2024-10-31 19:55:17 -05:00
hackerESQ 3b4f3b5efd docs:update screenshot 2024-10-31 19:31:58 -05:00
hackerESQ 791ba64dba feat:add switch to enable/disable ai chat 2024-10-31 17:33:48 -05:00
hackerESQ 8007e644d6 chore:clean up ai helper methods 2024-10-31 17:04:59 -05:00
hackerESQ 12cedd9e40 fix:use with instead of own for smart prompt 2024-10-31 16:42:55 -05:00
hackerESQ 0d1e6543d1 fix:use hosted privacy and terms 2024-10-31 16:39:28 -05:00
hackerESQ e0ab36ff61 fix:set app_port as default 2024-10-31 15:45:42 -05:00
hackerESQ 6231baefe9 fix:formatting for AI generated responses 2024-10-31 15:19:59 -05:00
hackerESQ 4c1da2308e feat:adds LLM capabilities to chat with your portfolios and holdings 2024-10-31 12:09:06 -05:00
hackerESQ 4cde6b82ea fix:simplify example env 2024-10-31 11:45:47 -05:00
hackerESQ 4e6dcd6ff4 feat:create custom data types for market data 2024-10-29 16:34:18 -05:00
hackerESQ 4f6e3c3711 feat:add name field to onboarding flow 2024-10-29 12:38:15 -05:00
hackerESQ 863627bb42 fix:trim whitespace from form fields 2024-10-29 12:38:05 -05:00
hackerESQ 07ebdaf77f chore:cleanup 2024-10-28 22:06:27 -05:00
hackerESQ 642d31dc31 fix:allow large cte functions 2024-10-28 21:55:56 -05:00
hackerESQ 1235abadd0 fix:properly scoped dividend counts 2024-10-28 21:17:53 -05:00
hackerESQ 25176c5a5f fix:don't max memory 2024-10-28 21:11:08 -05:00
hackerESQ 073ff88fa4 chore:note to self 2024-10-28 20:48:52 -05:00
hackerESQ 03dda7b947 fix:improve import performance 2024-10-28 20:31:29 -05:00
hackerESQ be859ad859 fix:improve modal performance and useability 2024-10-28 19:32:04 -05:00
hackerESQ d5f25c6f76 tests:import and exporting 2024-10-28 19:20:52 -05:00
hackerESQ 82a84cec97 fix: speeds up imports with batched transactions and daily changes 2024-10-28 18:35:14 -05:00
hackerESQ 41377757ec fix:only sync daily changes for weekdays 2024-10-28 17:50:45 -05:00
hackerESQ 140f7d5a93 tests:use correct span for sync daily changes 2024-10-28 17:00:51 -05:00
hackerESQ 0e9bb1de0f tests:add tests for setting owner_id on new portfolio create 2024-10-28 16:59:58 -05:00
hackerESQ 9e6f879d16 fix:autogrow defaults properly 2024-10-28 13:07:45 -05:00
hackerESQ 28c326a34a fix:internal links 2024-10-26 11:22:43 -05:00
hackerESQ bc6251f22a chore:typos and links 2024-10-26 11:22:00 -05:00
hackerESQ 6868c239a5 fix:wrong key used for daily change time of day config 2024-10-26 11:18:08 -05:00
hackerESQ b327059400 feat:verify email after onboarding flow 2024-10-25 22:14:36 -05:00
hackerESQ 57495d36d8 fix:optimize portfolio_users query 2024-10-25 22:06:46 -05:00
hackerESQ 89c1892013 Merge branch 'main' of https://github.com/investbrainapp/investbrain 2024-10-25 21:36:14 -05:00
hackerESQ 7b4e16a9a1 fix:remove sso from onboarding flow 2024-10-25 21:36:13 -05:00
hackerESQ d6631fb9e1 fix:remove sso from onboarding flow 2024-10-25 21:35:38 -05:00
hackerESQ 06f3eaaaf7 fix:typo 2024-10-25 21:32:17 -05:00
hackerESQ 1671abb5ee chore:add vapor yml file to gitignore 2024-10-25 21:28:10 -05:00
hackerESQ d4af0436be chore:add s3 flysystem 2024-10-25 21:18:37 -05:00
hackerESQ 9d9baa8857 fix:math for % earnings 2024-10-25 21:15:43 -05:00
hackerESQ ff476ad406 fix:speed up daily change sync 2024-10-25 21:06:45 -05:00
hackerESQ 0409677626 docs:typos and add new env options 2024-10-25 19:06:16 -05:00
hackerESQ 31af05bf70 fix:drawer not able to scroll 2024-10-25 16:12:57 -05:00
hackerESQ 0434bc6961 feat:configurable time of day to capture daily changes 2024-10-25 15:41:07 -05:00
hackerESQ 194ad4a532 fix:favicon 2024-10-25 15:24:48 -05:00
hackerESQ c19896beae Merge branch 'main' of https://github.com/investbrainapp/investbrain 2024-10-25 13:37:39 -05:00
hackerESQ 66311a1c4c fix:update responsible disclosure language in privacy notice 2024-10-25 13:37:38 -05:00
hackerESQ 6aa7910af1 fix:update responsible disclosure language in privacy notice 2024-10-25 13:37:22 -05:00
hackerESQ cd47abddc6 fix:exclude wishlists from performance chart 2024-10-24 20:14:14 -05:00
hackerESQ 9788070a16 feat:finalize background imports 2024-10-24 18:07:25 -05:00
hackerESQ 46531ce4fa fix:sync daily changes AFTER import 2024-10-24 17:39:26 -05:00
hackerESQ 400ee1c6f2 fix:password rules should be enforced 2024-10-24 17:20:55 -05:00
hackerESQ 8a3d3d1d34 fix:vapor doesn't like static attributes 2024-10-24 17:05:37 -05:00
hackerESQ 3c41759cf3 feat:open import export without verification 2024-10-24 16:49:08 -05:00
hackerESQ 336645d8e2 feat:email on fail/success import 2024-10-24 16:36:54 -05:00
hackerESQ 11ae07d69f fix: enable queued jobs to create portfolios with owners 2024-10-24 15:59:54 -05:00
hackerESQ 81ed440404 fix:ensure cast 2024-10-24 14:49:29 -05:00
hackerESQ 3c368310ad feat:improved background importing 2024-10-24 14:48:24 -05:00
hackerESQ 7543c0a865 fix:accessibility of checkbox 2024-10-24 11:36:50 -05:00
hackerESQ ff725e0119 feat:adds user option to sync and refresh commands 2024-10-23 16:57:55 -05:00
hackerESQ ab24b528d1 chore:adds prefix to temp files created by laravel excel 2024-10-23 16:29:44 -05:00
hackerESQ feab24ed2f feat:make uploads work for vapor 2024-10-23 16:08:58 -05:00
hackerESQ b441fb3953 fix:allow customizing livewire temp upload disk via env 2024-10-23 14:13:43 -05:00
hackerESQ 812e672c11 feat:spinner for profile photo uploads 2024-10-23 14:13:20 -05:00
hackerESQ 2995f8b37e fix:remove tagging from caches to enable dynamo db 2024-10-23 13:43:34 -05:00
hackerESQ 339de1ac9a chore:add vapor hidden directory to git ignore 2024-10-23 13:35:23 -05:00
hackerESQ 22cab746e6 feat:allow no social login providers 2024-10-23 12:07:32 -05:00
hackerESQ f99efa9ddf fix:enabled login providers should be an empty string by default 2024-10-23 12:00:33 -05:00
hackerESQ 10e00e6ef6 chore:move market data seeder csv to vapor accessible location 2024-10-23 11:41:15 -05:00
hackerESQ d53d1a3ed3 tests:adds testing to portfolio access policy 2024-10-22 22:24:39 -05:00
hackerESQ 5a04c33f13 chore:simplify connected account verifications 2024-10-22 21:24:04 -05:00
hackerESQ b6a123a90f feat:onboarding flow for new users 2024-10-22 20:29:54 -05:00
hackerESQ 5756fa06d7 feat:invitation email for shared portfolios 2024-10-22 17:39:42 -05:00
hackerESQ d1dbf3af62 fix:improve access controls and language
also adds improved dialogs / modals
2024-10-22 16:48:53 -05:00
hackerESQ c1a4a44024 fix:more title case fixes 2024-10-22 12:41:18 -05:00
hackerESQ 965303b6b0 fix:sentence case for translations 2024-10-22 12:36:43 -05:00
hackerESQ 6b424e2dcc fix:mobile responsive for holdings table 2024-10-22 12:13:36 -05:00
hackerESQ 3fd66d1138 feat:add auto grow text box 2024-10-21 23:02:18 -05:00
hackerESQ 740a29ce04 fix:types for new user 2024-10-21 22:33:02 -05:00
hackerESQ 39160d654b chore:clean up migrations 2024-10-21 22:24:42 -05:00
hackerESQ f93bfad3ce feat:adds ability to share portfolios
also includes basic permissions and authorization
2024-10-21 22:23:20 -05:00
hackerESQ 63c4c1c228 fix:update dividend options to manage holding 2024-10-20 20:00:06 -05:00
hackerESQ a3d5ee0d1b fix:run daily change at 11 pm 2024-10-20 17:25:59 -05:00
hackerESQ 8b067ece84 chores:add email components 2024-10-20 14:25:06 -05:00
hackerESQ 82dde818e6 feat:use provider name in social error messages 2024-10-20 14:17:09 -05:00
hackerESQ dd1e5c836c fix:clean up status ui 2024-10-20 14:10:24 -05:00
hackerESQ 64cfdb32a9 fix:use correct key 2024-10-20 14:06:58 -05:00
hackerESQ f793eb83c5 fix:use correct class 2024-10-20 14:06:07 -05:00
hackerESQ f8d54d3813 feat:catch error on declined permissions 2024-10-20 14:04:26 -05:00
hackerESQ 318f8dd940 fix:only show dividends where qty > 0 2024-10-20 13:28:40 -05:00
hackerESQ bf8478a43f feat:persist social logins 2024-10-20 13:21:29 -05:00
hackerESQ e97e927ca3 fix:mobile improvements 2024-10-20 13:15:34 -05:00
hackerESQ 6f847b9033 fix:mobile improvements 2024-10-20 10:06:06 -05:00
hackerESQ 2802a018b9 cleanup 2024-10-20 09:41:40 -05:00
hackerESQ 5555e95e48 clean up social login verifications 2024-10-20 09:41:25 -05:00
hackerESQ 6ce9833e66 rename method 2024-10-19 23:14:15 -05:00
hackerESQ 99c5ad3979 adds social login 2024-10-19 23:11:04 -05:00
hackerESQ bcb1820095 fix test 2024-10-19 10:42:28 -05:00
hackerESQ 6e75713589 adds reinvested dividend to import 2024-10-18 22:21:12 -05:00
hackerESQ 074cfa70fb formatting for reinvestments 2024-10-18 22:18:25 -05:00
hackerESQ 3cb0ad5c86 fix math for reinvestments 2024-10-18 22:08:52 -05:00
hackerESQ da9e7dd5c7 fix daily change imports 2024-10-18 21:59:35 -05:00
hackerESQ 104096471d better way to import daily changes 2024-10-18 21:34:02 -05:00
hackerESQ 83c5561edb formatting link on import export page 2024-10-18 21:21:20 -05:00
hackerESQ 9d1e17cfc0 chart mobile improvements 2024-10-18 21:16:44 -05:00
hackerESQ 6e14852f55 more improvements to header mobile 2024-10-18 21:14:37 -05:00
hackerESQ 34a8de221f make header more mobile responsible 2024-10-18 21:06:05 -05:00
hackerESQ 51c33ebec0 adds dividend re-investment feature 2024-10-18 20:46:22 -05:00
hackerESQ e4d45f391c truncate overflowing text 2024-10-18 19:51:42 -05:00
hackerESQ 23615c7309 chart stylying 2024-10-18 19:42:53 -05:00
hackerESQ 1c774096ab use translation 2024-10-18 14:59:10 -05:00
hackerESQ d70eeb6a0b update dependencies 2024-10-15 13:27:00 -05:00
hackerESQ 31b551e34a fix:mobile support 2024-10-01 13:58:17 -05:00
hackerESQ 914d65574b fix:broken tests 2024-10-01 13:41:50 -05:00
hackerESQ 231c9ffc6e fix:don't run dividend sync daily (run odd weekdays) 2024-10-01 13:41:40 -05:00
hackerESQ 9e173ecc35 fix:order by asc on date 2024-09-27 10:56:11 -05:00
hackerESQ 367fd7802b fix:order of data series 2024-09-27 10:51:43 -05:00
hackerESQ 267049b87f remove wip 2024-09-25 21:00:54 -05:00
hackerESQ ed4d955507 debug for dailychanges 2024-09-25 20:59:09 -05:00
hackerESQ 32fed82772 scope dashboard to mydailychanges 2024-09-25 19:41:48 -05:00
hackerESQ 90dafccec6 use period instead of data date keys 2024-09-23 19:39:49 -05:00
hackerESQ 7d119a8c24 simplify tests and daily change calculations 2024-09-23 15:09:35 -05:00
hackerESQ d0fbf44fa0 workaround for missing close value in market history data 2024-09-22 22:20:47 -05:00
hackerESQ dc1042e736 update realized gain calculation 2024-09-19 21:32:10 -05:00
hackerESQ 5fbe1ef9d4 show dividends on apex chart 2024-09-19 21:31:54 -05:00
hackerESQ 9d808cd447 rate limit on import 2024-09-19 21:26:29 -05:00
hackerESQ 5415c62d49 force refresh all dividends 2024-09-19 21:26:15 -05:00
hackerESQ e69a8fa4e6 add force option for dividends and splits 2024-09-19 21:25:56 -05:00
hackerESQ 80b25115a3 format chart 2024-09-18 23:31:03 -05:00
hackerESQ 5bcbec82f4 handle math in PHP 2024-09-18 23:19:11 -05:00
hackerESQ ab491971e4 fix force option 2024-09-18 23:03:23 -05:00
hackerESQ 2b9c3fb469 use mysql 8.0 2024-09-18 22:56:00 -05:00
hackerESQ 83abdd4169 wip 2024-09-18 22:42:09 -05:00
hackerESQ 7bdf22d188 rounding 2024-09-18 22:10:02 -05:00
hackerESQ 99830d1a55 remove inadvertent early return 2024-09-18 21:35:32 -05:00
hackerESQ 85660d7b9d force refresh market data when importing 2024-09-18 21:16:41 -05:00
hackerESQ 5fa3d6a83c enable force option for refresh market data 2024-09-18 21:15:52 -05:00
hackerESQ 17e9bce1ae ensure dividend data starts after marketdata sync 2024-09-18 21:09:50 -05:00
hackerESQ 854fa1c7e9 cast values to float 2024-09-18 20:42:13 -05:00
hackerESQ 02a3a1ea9f call refresh market data first 2024-09-18 20:31:57 -05:00
hackerESQ 1db90cce48 ensure we refresh all market data
including dividends when importing
2024-09-18 20:14:30 -05:00
hackerESQ 0748ff012d show loader on export 2024-09-18 20:03:26 -05:00
hackerESQ 5bb601f869 move to custom x-form component and fix htmlspecialentities issue 2024-09-18 19:51:05 -05:00
hackerESQ 835c2115f2 sync daily changes and market data after importing 2024-09-18 19:50:31 -05:00
256 changed files with 10046 additions and 37561 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/framework/logs/*
+46 -28
View File
@@ -1,25 +1,52 @@
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
APP_URL=http://localhost
ASSET_URL="${APP_URL}"
APP_PORT=8000
SELF_HOSTED=true
# Port for NGINX to listen on
APP_PORT=8000
# Used internally to generate absolute links
APP_URL="http://localhost:${APP_PORT}"
# Webroot for static assets (css, js, images, etc)
ASSET_URL="${APP_URL}"
# Enables or disables new user registration
REGISTRATION_ENABLED=true
# Enable or disable AI chat feature
AI_CHAT_ENABLED=false
# API key for OpenAI (for Llama support, see docs)
OPENAI_API_KEY=
OPENAI_ORGANIZATION=
# Market data provider to use (comma separated list)
MARKET_DATA_PROVIDER=yahoo
ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY=
# Cadence to refresh market data (in minutes)
MARKET_DATA_REFRESH=30
DAILY_CHANGE_TIME=
#### Advanced configurations ####
ENABLED_LOGIN_PROVIDERS=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
APP_NAME=Investbrain
APP_TIMEZONE=UTC
APP_ENV=production
APP_DEBUG=true
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US SELF_HOSTED=true
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=investbrain-mysql DB_HOST=investbrain-mysql
@@ -34,17 +61,13 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
CACHE_STORE=redis CACHE_STORE=redis
CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=predis REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1 REDIS_HOST=investbrain-redis
REDIS_PATH=/tmp/database_server.sock REDIS_PATH=/tmp/database_server.sock
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
@@ -58,11 +81,6 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MARKET_DATA_PROVIDER=yahoo
MARKET_DATA_REFRESH=30
ALPHAVANTAGE_API_KEY=
FINNHUB_API_KEY=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -0,0 +1,58 @@
name: Build and push Docker images
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-22.04 #ubuntu-latest
steps:
- name: Increase swap space
run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GIT_HUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Extract version from tag
id: extract-version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
file: ./docker/Dockerfile
push: true
tags: |
investbrainapp/investbrain:latest
investbrainapp/investbrain:${{ env.version }}
ghcr.io/investbrainapp/investbrain:latest
ghcr.io/investbrainapp/investbrain:${{ env.version }}
+3
View File
@@ -1,3 +1,4 @@
/packages
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
/public/build /public/build
@@ -19,3 +20,5 @@ yarn-error.log
/.idea /.idea
/.vscode /.vscode
.DS_Store .DS_Store
vapor.yml
.vapor
+105 -26
View File
@@ -1,38 +1,60 @@
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p> <p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p>
[![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 helps you manage and track the performance of your investments. Investbrain is a smart open-source investment tracker that helps you manage, track, and make informed decisions about your investments.
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/screenshot.png" width="100%" alt="Investbrain Screenshot"></a></p> <p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/screenshot.png" width="100%" alt="Investbrain Screenshot"></a></p>
## Table of contents
- [Under the hood](#under-the-hood)
- [Install (self hosting)](#self-hosting)
- [Chat with your holdings](#chat-with-your-holdings)
- [Market data providers](#market-data-providers)
- [Import / Export](#import--export)
- [Configuration](#configuration)
- [Updating](#updating)
- [Command line utilities](#command-line-utilities)
- [Troubleshooting](#troubleshooting)
- [Testing](#testing)
## Under the hood ## Under the hood
Investbrain is a Laravel PHP web application that leverages Livewire, Mary UI, and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! Finally, of course we have robust support for i18n, a11y, and dark mode. Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
## Installation ## Self hosting
For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which downloads all the necessary dependencies and seamlessly builds everything you need to get started quickly! For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which uses the official Investbrain Docker image and includes all the necessary dependencies to seamlessly build everything you need to get started quickly!
Before getting started, you should already have the following installed on your machine: [Docker Engine](https://docs.docker.com/engine/install/), [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git), and a wild sense of adventure. Before getting started, you should already have [Docker Engine](https://docs.docker.com/engine/install/) installed on your machine.
Ready? Let's get started! Ready? Let's get started!
First, you can clone this repository: **1. Download copy of Docker Compose file**
Grab a copy of the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) using `wget`, `curl` or similar:
```bash ```bash
git clone https://github.com/investbrainapp/investbrain.git && cd investbrain curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker-compose.yml
``` ```
Then, build the Docker image and bring up the container (this will take a few minutes): **2. Set your environment**
```bash Adjust the `environment` properties in the compose file to your preferences.
docker compose up
```
In the previous step, all of the default configurations are set automatically. This includes creating a .env file and setting the required Laravel `APP_KEY`. **Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
If everything worked as expected, you should now be able to access Investbrain in the browser at. You should create an account by visiting: **3. Run `docker compose up`**
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
```bash ```bash
http://localhost:8000/register http://localhost:8000/register
@@ -40,21 +62,31 @@ http://localhost:8000/register
Congrats! You've just installed Investbrain! Congrats! You've just installed Investbrain!
## Chat with your holdings
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
When self-hosting, you can enable the chat assistant by configuring your OpenAI Secret Key and Organization ID in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file. Navigate to OpenAI to [create your keys](https://platform.openai.com/api-keys).
If you are self-hosting your own large language models ("LLMs") that expose an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you can update the `OPENAI_BASE_URI` configuration to your self-hosted instance. Ensure you also update the `OPENAI_MODEL` to an available model.
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
## Market data providers ## Market data providers
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails. Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
### Configuration ### Configuration
You can specify the provider you want to use in your .env file: You can specify the market data provider you want to use in your environment variables:
```bash ```bash
MARKET_DATA_PROVIDER=yahoo MARKET_DATA_PROVIDER=yahoo
``` ```
You can also use Investbrain's built-in fallback mechanism to ensure reliable data access, even if a provider fails. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully. You can also use Investbrain's built-in fallback mechanism to ensure reliable data access. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully.
Your selected providers should be listed in your .env file. Each should be separated by a comma: Your selected providers should be listed in your environment variables. Each should be separated by a comma:
```bash ```bash
MARKET_DATA_PROVIDER=yahoo,alphavantage MARKET_DATA_PROVIDER=yahoo,alphavantage
@@ -64,7 +96,7 @@ In the above example, Yahoo Finance will be attempted first and the Alpha Vantag
### Custom providers ### Custom providers
If you wish to create your own market data provider, you can create your own implementation of the [MarketDataInterface](https://github.com/investbrainapp/investbrain/blob/main/app/Interfaces/MarketData/MarketDataInterface.php). You can refer to any existing market data implementation as an examples. If you wish to create your own market data provider, you can create your own implementation of the [MarketDataInterface](https://github.com/investbrainapp/investbrain/blob/main/app/Interfaces/MarketData/MarketDataInterface.php). You can refer to any existing market data implementation as an example.
Once you've created your market data implementation, be sure add your custom provider to the Investbrain configuration file, under the interfaces section: Once you've created your market data implementation, be sure add your custom provider to the Investbrain configuration file, under the interfaces section:
@@ -85,36 +117,58 @@ MARKET_DATA_PROVIDER=yahoo,alphavantage,custom_provider
Feel free to submit a PR with any custom providers you create. Feel free to submit a PR with any custom providers you create.
## Import / Export
Investbrain includes a convenient feature which allows you to maintain the portability of your portfolios and transaction data.
### Import
Imports are "upserted" to the database. If the record does not already exist in the database, the record will be created. However, when a portfolio or transaction exists (i.e. the record's ID matches an existing record), the record will be updated. This way, you can simultaneously create new records, but also bulk update records.
### Export
Exporting your portfolios and transactions is a convenient way to back-up your Investbrain data. It is also a convenient way to maintain portability of *your* data.
## Configuration ## Configuration
There are several optional configurations available when installing using the recommended [Docker method](#Installation). These options are configurable using an environment file. Changes can be made in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file before installation. There are several optional configurations available when installing using the recommended [Docker method](#self-hosting). These options are configurable using an environment file. Configurations can be added to your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file or to the `environment` property in the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file.
| Option | Description | Default | | Option | Description | Default |
| ------------- | ------------- | ------------- | | ------------- | ------------- | ------------- |
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost | | APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 | | APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo | | MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` | | ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
| FINNHUB_API_KEY | If using the Finnhub provider | `null` | | FINNHUB_API_KEY | If using the Finnhub provider | `null` |
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` |
| OPENAI_ORGANIZATION | OpenAI org id (required for AI chat) | `null` |
| OPENAI_MODEL | The selected LLM used for AI chat | gpt-4o |
| OPENAI_BASE_URI | The URI for your self-hosted LLM | api.openai.com/v1 |
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, so if you decide to make any changes to these default configurations, you'll have to restart the Docker containers before your changes take effect.
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file and are cached during run-time. If change any environment configurations, you'll have to restart the container before your changes take effect.
## Updating ## Updating
To update Investbrain using the recommended [Docker installation](#Installation) method, you just need to stop the running containers: To update Investbrain using the recommended [Docker installation](#self-hosting) method, you just need to stop the running containers:
```bash ```bash
docker compose stop docker compose stop
``` ```
Then pull the latest updates from this repository using git: Then pull the latest Docker image:
```bash ```bash
git pull docker image pull investbrainapp/investbrain:latest
``` ```
Then bring the containers back up! Finally bring the containers back up!
```bash ```bash
docker compose up docker compose up
@@ -143,9 +197,34 @@ Just to be safe, we recommend backing up your portfolios before using these comm
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) | | sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). | | sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
## Troubleshooting
If you are facing issues with Investbrain, it can be handy to monitor the application's logs:
```bash
docker exec -it investbrain-app cat storage/logs/laravel.log
```
or you can live monitor logs using `tail`:
```bash
docker exec -it investbrain-app tail -f storage/logs/laravel.log
```
### Common issues
<details>
**<summary>Market data not refreshing on fresh install</summary>**
If you're unable to refresh market data out of the box (i.e. your market data provider is set to Yahoo), there is a chance Yahoo is being blocked by a firewall or adblocker. Pihole is known to block `fc.yahoo.com` which is the domain used to query Yahoo.
Once you whitelist `fc.yahoo.com` in pihole, your market data should begin populating!
</details>
## Testing ## Testing
Investbrain has a complete PHPUnit test suite that creates an in-memory SQLite database and runs any queued jobs synchronously using Laravel's array driver. You can run the entire Investbrain test suite from within the Docker container by running: Investbrain has a robus PHPUnit test suite that creates an in-memory SQLite database and runs any queued jobs synchronously using Laravel's array driver. You can run the entire Investbrain test suite from within the Docker container by running:
```bash ```bash
docker exec -it investbrain-app php artisan test docker exec -it investbrain-app php artisan test
@@ -169,7 +248,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
+12
View File
@@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.0.x | :white_check_mark: |
| < 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.
+9
View File
@@ -1,8 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Traits\WithTrimStrings;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Fortify\Contracts\CreatesNewUsers;
@@ -11,6 +14,12 @@ use Laravel\Jetstream\Jetstream;
class CreateNewUser implements CreatesNewUsers class CreateNewUser implements CreatesNewUsers
{ {
use PasswordValidationRules; use PasswordValidationRules;
use WithTrimStrings;
public function trimExceptions()
{
return ['password'];
}
/** /**
* Validate and create a newly registered user. * Validate and create a newly registered user.
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
@@ -1,8 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Traits\WithTrimStrings;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -10,6 +13,8 @@ use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{ {
use WithTrimStrings;
/** /**
* Validate and update the given user's profile information. * Validate and update the given user's profile information.
* *
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Models\User; use App\Models\User;
+6 -4
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Portfolio; use App\Models\Portfolio;
@@ -38,9 +40,9 @@ class CaptureDailyChange extends Command
*/ */
public function handle() public function handle()
{ {
Portfolio::with('holdings.market_data')->get()->each(function($portfolio){ Portfolio::with('holdings.market_data')->get()->each(function ($portfolio) {
$this->line('Capturing daily change for ' . $portfolio->title); $this->line('Capturing daily change for '.$portfolio->title);
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis'); $total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
@@ -48,7 +50,7 @@ class CaptureDailyChange extends Command
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars'); $realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
$total_market_value = $portfolio->holdings->sum(function($holding) { $total_market_value = $portfolio->holdings->sum(function ($holding) {
return $holding->market_data->market_value * $holding->quantity; return $holding->market_data->market_value * $holding->quantity;
}); });
@@ -58,7 +60,7 @@ class CaptureDailyChange extends Command
'total_cost_basis' => $total_cost_basis, 'total_cost_basis' => $total_cost_basis,
'total_gain' => $total_market_value - $total_cost_basis, 'total_gain' => $total_market_value - $total_cost_basis,
'total_dividends_earned' => $total_dividends, 'total_dividends_earned' => $total_dividends,
'realized_gains' => $realized_gains 'realized_gains' => $realized_gains,
]); ]);
}); });
} }
+18 -6
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
@@ -13,7 +15,9 @@ class RefreshDividendData extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'refresh:dividend-data'; protected $signature = 'refresh:dividend-data
{--force : Refresh all holdings}
{--user= : Limit refresh to user\'s holdings}';
/** /**
* The console command description. * The console command description.
@@ -39,11 +43,19 @@ class RefreshDividendData extends Command
*/ */
public function handle() public function handle()
{ {
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']); $holdings = Holding::distinct();
if (! ($this->option('force') ?? false)) {
$holdings->where('quantity', '>', 0);
}
if ($this->option('user')) {
$holdings->myHoldings($this->option('user'));
}
foreach ($holdings->get(['symbol']) as $holding) {
$this->line('Refreshing '.$holding->symbol);
foreach ($holdings as $holding) {
$this->line('Refreshing ' . $holding->symbol);
Dividend::refreshDividendData($holding->symbol); Dividend::refreshDividendData($holding->symbol);
} }
} }
+20 -7
View File
@@ -1,10 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Holding; use App\Models\Holding;
use App\Models\MarketData; use App\Models\MarketData;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RefreshMarketData extends Command class RefreshMarketData extends Command
{ {
@@ -14,7 +17,8 @@ class RefreshMarketData extends Command
* @var string * @var string
*/ */
protected $signature = 'refresh:market-data protected $signature = 'refresh:market-data
{--force= : Ignore refresh delay}'; {--force : Ignore refresh delay}
{--user= : Limit refresh to user\'s holdings}';
/** /**
* The console command description. * The console command description.
@@ -40,16 +44,25 @@ class RefreshMarketData extends Command
*/ */
public function handle() public function handle()
{ {
$force = $this->option('force') ?? false;
// get all symbols from market data // get all symbols from market data
$holdings = Holding::where('quantity', '>', 0) $holdings = Holding::where('quantity', '>', 0)
->select(['symbol']) ->select(['symbol'])
->distinct() ->distinct();
->get();
foreach ($holdings as $holding) { if ($this->option('user')) {
$this->line('Refreshing ' . $holding->symbol); $holdings->myHoldings($this->option('user'));
}
MarketData::getMarketData($holding->symbol); foreach ($holdings->get() as $holding) {
$this->line('Refreshing '.$holding->symbol);
try {
MarketData::getMarketData($holding->symbol, $force);
} catch (\Throwable $e) {
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
}
} }
} }
} }
+13 -7
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
@@ -14,7 +16,7 @@ class RefreshSplitData extends Command
* @var string * @var string
*/ */
protected $signature = 'refresh:split-data protected $signature = 'refresh:split-data
{--force= : Don\'t ask to confirm.}'; {--force : Refresh all holdings}';
/** /**
* The console command description. * The console command description.
@@ -40,12 +42,16 @@ class RefreshSplitData extends Command
*/ */
public function handle() public function handle()
{ {
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']); $holdings = Holding::distinct();
if (! ($this->option('force') ?? false)) {
$holdings->where('quantity', '>', 0);
}
foreach ($holdings->get(['symbol']) as $holding) {
$this->line('Refreshing '.$holding->symbol);
foreach ($holdings as $holding) {
$this->line('Refreshing ' . $holding->symbol);
Split::refreshSplitData($holding->symbol); Split::refreshSplitData($holding->symbol);
} }
} }
} }
+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) {
+11 -4
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;
@@ -12,7 +14,8 @@ class SyncHoldingData extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sync:holdings'; protected $signature = 'sync:holdings
{--user= : Limit refresh to user\'s holdings}';
/** /**
* The console command description. * The console command description.
@@ -39,10 +42,14 @@ class SyncHoldingData extends Command
public function handle() public function handle()
{ {
// get all holdings // get all holdings
$holdings = Holding::get(); $holdings = Holding::query();
foreach ($holdings as $holding) { if ($this->option('user')) {
$this->line('Refreshing ' . $holding->symbol); $holdings->myHoldings($this->option('user'));
}
foreach ($holdings->get() as $holding) {
$this->line('Refreshing '.$holding->symbol);
$holding->syncTransactionsAndDividends(); $holding->syncTransactionsAndDividends();
} }
+8 -10
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Exports; namespace App\Exports;
use App\Exports\Sheets\DailyChangesSheet; use App\Exports\Sheets\DailyChangesSheet;
@@ -14,18 +16,14 @@ class BackupExport implements WithMultipleSheets
public function __construct( public function __construct(
public bool $empty = false public bool $empty = false
) ) {}
{ }
/**
* @return array
*/
public function sheets(): array public function sheets(): array
{ {
return [ return [
new PortfoliosSheet($this->empty), new PortfoliosSheet($this->empty),
new TransactionsSheet($this->empty), new TransactionsSheet($this->empty),
new DailyChangesSheet($this->empty) new DailyChangesSheet($this->empty),
]; ];
} }
} }
+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\DailyChange; use App\Models\DailyChange;
@@ -11,7 +13,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
{ {
public function __construct( public function __construct(
public bool $empty = false public bool $empty = false
) { } ) {}
public function headings(): array public function headings(): array
{ {
@@ -21,23 +23,20 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
'Total Market Value', 'Total Market Value',
'Total Cost Basis', 'Total Cost Basis',
'Total Gain', 'Total Gain',
'Total Dividends', 'Total Dividends Earned',
'Realized Gains', 'Realized Gains',
'Annotation' 'Annotation',
]; ];
} }
/** /**
* @return \Illuminate\Support\Collection * @return \Illuminate\Support\Collection
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : DailyChange::myDailyChanges()->get(); return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
} }
/**
* @return string
*/
public function title(): string public function title(): string
{ {
return 'Daily Changes'; return 'Daily Changes';
+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';
+9 -9
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
{ {
@@ -24,23 +26,21 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
'Cost Basis', 'Cost Basis',
'Sale Price', 'Sale Price',
'Split', 'Split',
'Reinvested Dividend',
'Date', 'Date',
'Created', 'Created',
'Updated' 'Updated',
]; ];
} }
/** /**
* @return \Illuminate\Support\Collection * @return \Illuminate\Support\Collection
*/ */
public function collection() public function collection()
{ {
return $this->empty ? collect() : Transaction::myTransactions()->get(); return $this->empty ? collect() : Transaction::myTransactions()->get();
} }
/**
* @return string
*/
public function title(): string public function title(): string
{ {
return 'Transactions'; return 'Transactions';
+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,59 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Requests\TransactionRequest;
use App\Http\Resources\TransactionResource;
use App\Models\Transaction;
use HackerEsq\FilterModels\FilterModels;
use Illuminate\Support\Facades\Gate;
class TransactionController extends ApiController
{
public function index(FilterModels $filters)
{
$filters->setQuery(Transaction::query());
$filters->setScopes(['myTransactions']);
$filters->setSearchableColumns(['symbol']);
return TransactionResource::collection($filters->paginated());
}
public function store(TransactionRequest $request)
{
Gate::authorize('fullAccess', $request->portfolio);
$transaction = Transaction::create($request->validated());
return TransactionResource::make($transaction);
}
public function show(Transaction $transaction)
{
Gate::authorize('readOnly', $transaction->portfolio);
return TransactionResource::make($transaction);
}
public function update(TransactionRequest $request, Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->update($request->validated());
return TransactionResource::make($transaction);
}
public function destroy(Transaction $transaction)
{
Gate::authorize('fullAccess', $transaction->portfolio);
$transaction->delete();
return response()->noContent();
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\ApiControllers;
use App\Http\ApiControllers\Controller as ApiController;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
class UserController extends ApiController
{
public function me(Request $request)
{
return UserResource::make($request->user());
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ConnectedAccount;
use App\Models\User;
use App\Notifications\VerifyConnectedAccountNotification;
use Exception;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\MessageBag;
use Laravel\Socialite\Facades\Socialite;
class ConnectedAccountController extends Controller
{
/**
* Redirect the user to the GitHub authentication page.
*/
public function redirectToProvider(string $provider)
{
$this->validateProvider($provider);
return Socialite::driver($provider)->redirect();
}
/**
* Obtain the user information from GitHub.
*/
public function handleProviderCallback(string $provider)
{
$this->validateProvider($provider);
try {
$providerUser = Socialite::driver($provider)->user();
} catch (Exception $e) {
return redirect(route('login'))
->with('errors', new MessageBag([__('Could not login using :provider. Try again later.', ['provider' => config("services.$provider.name")])]));
}
// check if this account is already linked
$connected_account = ConnectedAccount::firstOrNew([
'provider' => $provider,
'provider_id' => $providerUser->id,
], [
'token' => $providerUser->token,
'secret' => $providerUser->tokenSecret,
'refresh_token' => $providerUser->refreshToken,
'expires_at' => $providerUser->expiresIn,
'verified_at' => false,
]);
// already linked and verified, let's go login!
if (
$connected_account->exists
&& ! is_null($connected_account->verified_at)
) {
Auth::login($connected_account->user, true);
return redirect(route('dashboard'));
}
// new user, let's create one
if (! $user = User::where('email', $providerUser->email)->first()) {
$user = User::create([
'name' => $providerUser->name,
'email' => $providerUser->email,
'email_verified_at' => now(),
]);
$connected_account->user_id = $user->id;
$connected_account->verified_at = now();
$connected_account->save();
Auth::login($user, true);
return redirect(route('dashboard'));
}
// email exists already, send verification link
$connected_account->user_id = $user->id;
$connected_account->save();
$user->notify(new VerifyConnectedAccountNotification($connected_account->id));
return redirect(route('login'))
->with('status', __(
'Account already exists. Check your email to connect your :provider account.',
['provider' => config("services.$provider.name")]
));
}
protected function validateProvider($provider): void
{
if (! in_array($provider, explode(',', config('services.enabled_login_providers')))) {
throw new Exception('Please provide a valid social provider.');
}
}
public function verify(ConnectedAccount $connected_account)
{
if (! $connected_account->verified_at) {
// mark request as verified
$connected_account->verified_at = now();
$connected_account->save();
// mark user as verified
$connected_account->user->email_verified_at = now();
$connected_account->user->save();
Auth::login($connected_account->user, true);
}
return redirect(route('dashboard'))->with('toast', json_encode([
'toast' => [
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
'description' => null,
'css' => 'alert-success',
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
'position' => 'toast-top toast-end',
'timeout' => '5000',
],
]));
}
}
+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
+9 -6
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,15 +17,16 @@ class DashboardController extends Controller
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']); $user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
// get portfolio metrics // get portfolio metrics
$metrics = cache()->tags(['metrics', 'dashboard', $user->id])->remember( $metrics = cache()->remember(
'dashboard-metrics-' . $user->id, 'dashboard-metrics-'.$user->id,
10, 10,
function () { function () {
return return
Holding::query() Holding::query()
->myHoldings() ->myHoldings()
->withPortfolioMetrics() ->withoutWishlists()
->first(); ->withPortfolioMetrics()
->first();
} }
); );
+13 -15
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,27 +10,23 @@ use Illuminate\Http\Request;
class HoldingController extends Controller class HoldingController extends Controller
{ {
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Request $request, Portfolio $portfolio, String $symbol) public function show(Request $request, Portfolio $portfolio, string $symbol)
{ {
$holding = Holding::with([ $holding = Holding::with([
'market_data', 'market_data',
'transactions' => function ($query) use ($symbol) { 'transactions' => function ($query) use ($symbol) {
$query->where('transactions.symbol', $symbol); $query->where('transactions.symbol', $symbol);
} },
]) ])
->symbol($symbol) ->symbol($symbol)
->portfolio($portfolio->id) ->portfolio($portfolio->id)
->firstOrFail(); ->firstOrFail();
// if ($holding->quantity <= 0) { $formattedTransactions = $holding->getFormattedTransactions();
// return redirect(route('portfolio.show', ['portfolio' => $portfolio->id])); return view('holding.show', compact(['portfolio', 'holding', 'formattedTransactions']));
// }
return view('holding.show', compact(['portfolio', 'holding']));
} }
} }
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Portfolio;
use App\Models\User;
use Illuminate\Http\Request;
class InvitedOnboardingController extends Controller
{
/**
* Check if the invited user needs a password?
*/
public function __invoke(Request $request, Portfolio $portfolio, User $user)
{
if (! $request->hasValidSignature()) {
abort(401, 'Invalid signature');
}
// user doesn't have password
if (is_null($user->password)) {
// route to create password form
return view('auth.invited-onboarding', [
'portfolio' => $portfolio,
'user' => $user,
]);
}
// redirect user to portfolio
return redirect(route('portfolio.show', ['portfolio' => $portfolio->id]));
}
}
+18 -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 App\Models\DailyChange; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PortfolioController extends Controller class PortfolioController extends Controller
{ {
/** /**
* Show the form for creating a new resource. * Show the form for creating a new resource.
*/ */
@@ -20,22 +22,26 @@ class PortfolioController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Portfolio $portfolio) public function show(Request $request, Portfolio $portfolio)
{ {
Gate::authorize('readOnly', $portfolio);
$portfolio->load(['transactions', 'holdings']); $portfolio->load(['transactions', 'holdings']);
// get portfolio metrics // get portfolio metrics
$metrics = cache()->tags(['metrics', 'portfolio', $portfolio->id])->remember( $metrics = cache()->remember(
'portfolio-metrics-' . $portfolio->id, 'portfolio-metrics-'.$portfolio->id,
60, 60,
function () use ($portfolio) { function () use ($portfolio) {
return Holding::query() return Holding::query()
->portfolio($portfolio->id) ->portfolio($portfolio->id)
->withPortfolioMetrics() ->withPortfolioMetrics()
->first(); ->first();
} }
); );
return view('portfolio.show', compact(['portfolio', 'metrics'])); $formattedHoldings = $portfolio->getFormattedHoldings();
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
} }
} }
@@ -1,10 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
class TransactionController extends Controller class TransactionController extends Controller
{ {
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
+4 -2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
@@ -14,7 +16,7 @@ class SetLocale
*/ */
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
if (!session()->has('locale')) { if (! session()->has('locale')) {
session()->put('locale', $request->getPreferredLanguage( session()->put('locale', $request->getPreferredLanguage(
config('app.available_locales') config('app.available_locales')
)); ));
@@ -24,4 +26,4 @@ class SetLocale
return $next($request); return $next($request);
} }
} }
+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;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Models\Portfolio;
use App\Rules\QuantityValidationRule;
use App\Rules\SymbolValidationRule;
class TransactionRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'portfolio' => Portfolio::find($this->requestOrModelValue('portfolio_id', 'transaction')),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'portfolio_id' => ['required', 'exists:portfolios,id'],
'symbol' => ['required', 'string', new SymbolValidationRule],
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
'quantity' => [
'required',
'numeric',
'min:0',
new QuantityValidationRule(
$this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'),
$this->requestOrModelValue('transaction_type', 'transaction'),
$this->requestOrModelValue('date', 'transaction')
),
],
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
];
if (! is_null($this->transaction)) {
$rules['portfolio_id'][0] = 'sometimes';
$rules['symbol'][0] = 'sometimes';
$rules['transaction_type'][0] = 'sometimes';
$rules['date'][0] = 'sometimes';
$rules['quantity'][0] = 'sometimes';
if (
$this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
&& $this->requestOrModelValue('sale_price', 'transaction') == null
) {
$rules['sale_price'][0] = 'required';
} elseif (
$this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
&& $this->requestOrModelValue('cost_basis', 'transaction') == null
) {
$rules['cost_basis'][0] = 'required';
}
}
return $rules;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class HoldingResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol,
'quantity' => $this->quantity,
'reinvest_dividends' => $this->reinvest_dividends,
'average_cost_basis' => $this->average_cost_basis,
'total_cost_basis' => $this->total_cost_basis,
'realized_gain_dollars' => $this->realized_gain_dollars,
'dividends_earned' => $this->dividends_earned,
'splits_synced_at' => $this->splits_synced_at,
'total_market_value' => $this->total_market_value,
'market_gain_dollars' => $this->market_gain_dollars,
'market_gain_percent' => $this->market_gain_percent,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+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,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TransactionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'symbol' => $this->symbol,
'portfolio_id' => $this->portfolio_id,
'transaction_type' => $this->transaction_type,
'quantity' => $this->quantity,
'cost_basis' => $this->cost_basis,
'sale_price' => $this->sale_price,
'split' => $this->split,
'reinvested_dividend' => $this->reinvested_dividend,
'date' => $this->date,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'profile_photo_url' => $this->profile_photo_url,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+49 -12
View File
@@ -1,37 +1,74 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports; namespace App\Imports;
use App\Imports\Sheets\PortfoliosSheet; use App\Console\Commands\RefreshDividendData;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\SyncDailyChange;
use App\Console\Commands\SyncHoldingData;
use App\Imports\Sheets\DailyChangesSheet; use App\Imports\Sheets\DailyChangesSheet;
use App\Imports\Sheets\PortfoliosSheet;
use App\Imports\Sheets\TransactionsSheet; use App\Imports\Sheets\TransactionsSheet;
use App\Models\BackupImport as BackupImportModel;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Maatwebsite\Excel\Concerns\Importable; use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithEvents; use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithMultipleSheets; use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Events\AfterImport;
use Maatwebsite\Excel\Events\BeforeImport;
use Maatwebsite\Excel\Events\ImportFailed;
class BackupImport implements WithMultipleSheets, WithEvents class BackupImport implements WithEvents, WithMultipleSheets
{ {
use Importable; use Importable;
/** public function __construct(
* @return array public BackupImportModel $backupImportModel
*/ ) {}
public function registerEvents(): array public function registerEvents(): array
{ {
return [ return [
// BeforeSheet::class => DB::commit(), BeforeImport::class => fn () => $this->backupImportModel->update([
// AfterSheet::class => Artisan::queue(RefreshHoldingData::class), 'status' => 'in_progress',
// AfterSheet::class => Artisan::call(RefreshHoldingData::class) 'message' => __('Import is in progress...'),
]),
AfterImport::class => function () {
$this->backupImportModel->update([
'status' => 'success',
'message' => 'Import completed successfully!',
'completed_at' => now(),
]);
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
->chain([
fn () => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
fn () => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
fn () => User::find($this->backupImportModel->user_id)->portfolios->each(function ($portfolio) {
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
}),
]);
},
ImportFailed::class => fn (ImportFailed $event) => $this->backupImportModel->update([
'status' => 'failed',
'message' => 'Error: '.substr($event->getException()->getMessage(), 0, 220),
'has_errors' => true,
'completed_at' => now(),
]),
]; ];
} }
public function sheets(): array public function sheets(): array
{ {
return [ return [
'Portfolios' => new PortfoliosSheet, 'Portfolios' => new PortfoliosSheet($this->backupImportModel),
'Transactions' => new TransactionsSheet, 'Transactions' => new TransactionsSheet($this->backupImportModel),
'Daily Changes' => new DailyChangesSheet, 'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
]; ];
} }
} }
+67 -29
View File
@@ -1,59 +1,97 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use Exception; use App\Imports\ValidatesPortfolioAccess;
use App\Models\BackupImport;
use App\Models\DailyChange; use App\Models\DailyChange;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection; use Illuminate\Support\Facades\DB;
use App\Imports\ValidatesPortfolioPermissions;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithChunkReading; use Maatwebsite\Excel\Events\BeforeSheet;
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
use ValidatesPortfolioPermissions; use ValidatesPortfolioAccess;
public function __construct(
public BackupImport $backupImport
) {}
public function registerEvents(): array
{
return [
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing daily changes...'),
]);
DB::beginTransaction();
},
];
}
public function collection(Collection $dailyChanges) public function collection(Collection $dailyChanges)
{ {
$this->validatePortfolioPermissions($dailyChanges); $dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
foreach ($dailyChanges as $dailyChange) { $this->validatePortfolioAccess($chunk);
DailyChange::updateOrCreate([ // have to cast to native values
'date' => $dailyChange['date'], $chunk = $chunk->map(function ($dailyChange) {
'portfolio_id' => $dailyChange['portfolio_id'],
],[ return [
'portfolio_id' => $dailyChange['portfolio_id'], 'total_market_value' => $dailyChange['total_market_value'],
'date' => $dailyChange['date'], 'total_cost_basis' => $dailyChange['total_cost_basis'],
'total_market_value' => $dailyChange['total_market_value'], 'total_gain' => $dailyChange['total_gain'],
'total_cost_basis' => $dailyChange['total_cost_basis'], 'total_dividends_earned' => $dailyChange['total_dividends_earned'],
'total_gain' => $dailyChange['total_gain'], 'realized_gains' => $dailyChange['realized_gains'],
'total_dividends_earned' => $dailyChange['total_dividends'], 'annotation' => $dailyChange['annotation'],
'realized_gains' => $dailyChange['realized_gains'], 'portfolio_id' => $dailyChange['portfolio_id'],
'annotation' => $dailyChange['annotation'], 'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d'),
]); ];
} });
DailyChange::upsert(
$chunk->toArray(),
['portfolio_id', 'date'],
[
'total_market_value',
'total_cost_basis',
'total_gain',
'total_dividends_earned',
'realized_gains',
'annotation',
'portfolio_id',
'date',
]
);
});
}
public function batchSize(): int
{
return 500;
} }
public function rules(): array public function rules(): array
{ {
return [ return [
'portfolio_id' => ['required', 'exists:portfolios,id'], 'portfolio_id' => ['required', 'uuid'],
'date' => ['required', 'date'], 'date' => ['required', 'date'],
'total_market_value' => ['sometimes', 'nullable', 'numeric'], 'total_market_value' => ['sometimes', 'nullable', 'numeric'],
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'total_gain' => ['sometimes', 'nullable', 'numeric'], 'total_gain' => ['sometimes', 'nullable', 'numeric'],
'total_dividends' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'realized_gains' => ['sometimes', 'nullable', 'numeric'], 'realized_gains' => ['sometimes', 'nullable', 'numeric'],
'annotation' => ['sometimes', 'nullable', 'string'], 'annotation' => ['sometimes', 'nullable', 'string'],
]; ];
} }
public function chunkSize(): int
{
return 500;
}
} }
+32 -8
View File
@@ -1,29 +1,53 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use App\Models\BackupImport;
use App\Models\Portfolio; use App\Models\Portfolio;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection; use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Events\BeforeSheet;
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows class PortfoliosSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
public function __construct(
public BackupImport $backupImport
) {}
public function registerEvents(): array
{
return [
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing portfolios...'),
]);
DB::beginTransaction();
},
];
}
public function collection(Collection $portfolios) public function collection(Collection $portfolios)
{ {
foreach ($portfolios as $portfolio) { foreach ($portfolios as $index => $portfolio) {
Portfolio::unguard();
Portfolio::updateOrCreate([ Portfolio::unguard(); // ensures we can set an owner for the portfolio
'id' => $portfolio['portfolio_id']
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
'id' => $portfolio['portfolio_id'],
], [ ], [
'id' => $portfolio['portfolio_id'] ?? null, 'id' => $portfolio['portfolio_id'] ?? null,
'title' => $portfolio['title'], 'title' => $portfolio['title'],
'wishlist' => $portfolio['wishlist'] ?? false, 'wishlist' => $portfolio['wishlist'] ?? false,
'notes' => $portfolio['notes'], 'notes' => $portfolio['notes'],
'owner_id' => $this->backupImport->user_id,
]); ]);
} }
} }
@@ -31,7 +55,7 @@ class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, S
public function rules(): array public function rules(): array
{ {
return [ return [
'portfolio_id' => ['sometimes', 'nullable'], 'portfolio_id' => ['sometimes', 'nullable', 'uuid'],
'title' => ['required', 'string'], 'title' => ['required', 'string'],
'wishlist' => ['sometimes', 'nullable', 'boolean'], 'wishlist' => ['sometimes', 'nullable', 'boolean'],
'notes' => ['sometimes', 'nullable', 'string'], 'notes' => ['sometimes', 'nullable', 'string'],
+86 -35
View File
@@ -1,70 +1,121 @@
<?php <?php
declare(strict_types=1);
namespace App\Imports\Sheets; namespace App\Imports\Sheets;
use App\Imports\ValidatesPortfolioAccess;
use App\Models\BackupImport;
use App\Models\Holding;
use App\Models\Transaction; use App\Models\Transaction;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection; use Illuminate\Support\Facades\DB;
use App\Imports\ValidatesPortfolioPermissions; use Illuminate\Support\Str;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows; use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithChunkReading; use Maatwebsite\Excel\Events\BeforeSheet;
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
{ {
use ValidatesPortfolioPermissions; use ValidatesPortfolioAccess;
public function __construct(
public BackupImport $backupImport
) {}
public function registerEvents(): array
{
return [
BeforeSheet::class => function (BeforeSheet $event) {
DB::commit();
$this->backupImport->update([
'message' => __('Importing transactions...'),
]);
DB::beginTransaction();
},
];
}
public function collection(Collection $transactions) public function collection(Collection $transactions)
{ {
$this->validatePortfolioPermissions($transactions);
Transaction::withoutEvents(function () use ($transactions) {
foreach ($transactions->sortBy('date') as $transaction) { $transactions->chunk($this->batchSize())->each(function ($chunk) {
Transaction::where('id', $transaction['transaction_id']) $this->validatePortfolioAccess($chunk);
->firstOr(function () use ($transaction) {
$transaction = Transaction::make()->forceFill([ // have to cast to native values
'id' => $transaction['transaction_id'], $chunk = $chunk->map(function ($transaction) {
'symbol' => $transaction['symbol'],
'portfolio_id' => $transaction['portfolio_id'],
'transaction_type' => $transaction['transaction_type'],
'quantity' => $transaction['quantity'],
'cost_basis' => $transaction['cost_basis'] ?? 0,
'sale_price' => $transaction['sale_price'],
'split' => $transaction['split'] ?? null,
'date' => $transaction['date'],
]);
$transaction->save(); return [
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
'symbol' => strtoupper($transaction['symbol']),
'portfolio_id' => $transaction['portfolio_id'],
'transaction_type' => $transaction['transaction_type'],
'quantity' => $transaction['quantity'],
'cost_basis' => $transaction['cost_basis'] ?? 0,
'sale_price' => $transaction['sale_price'],
'split' => boolval($transaction['split']) ? 1 : 0,
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
'date' => Carbon::parse($transaction['date'])->format('Y-m-d'),
];
});
return $transaction; Transaction::upsert(
}) $chunk->toArray(),
->syncToHolding(); ['id'],
} [
'id',
'symbol',
'portfolio_id',
'transaction_type',
'quantity',
'cost_basis',
'sale_price',
'split',
'reinvested_dividend',
'date',
]
);
// stub out related holdings
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
->each(function ($holding) {
Holding::firstOrCreate([
'symbol' => $holding['symbol'],
'portfolio_id' => $holding['portfolio_id'],
], [
'quantity' => 0,
'average_cost_basis' => 0,
'splits_synced_at' => now(),
]);
});
}); });
}
public function batchSize(): int
{
return 500;
} }
public function rules(): array public function rules(): array
{ {
return [ return [
'transaction_id' => ['sometimes', 'nullable'], 'transaction_id' => ['sometimes', 'nullable', 'uuid'],
'symbol' => ['required', 'string'], 'symbol' => ['required', 'string'],
'portfolio_id' => ['required', 'exists:portfolios,id'], 'portfolio_id' => ['required', 'uuid'],
'quantity' => ['required', 'min:0', 'numeric'], 'quantity' => ['required', 'min:0', 'numeric'],
'transaction_type' => ['required', 'in:BUY,SELL'], 'transaction_type' => ['required', 'in:BUY,SELL'],
'date' => ['required', 'date'], 'date' => ['required', 'date'],
'quantity' => ['required', 'min:0', 'numeric'], 'quantity' => ['required', 'min:0', 'numeric'],
'split' => ['sometimes', 'nullable', 'boolean'],
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'], 'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'],
]; ];
} }
public function chunkSize(): int
{
return 500;
}
} }
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Imports;
use App\Models\Portfolio;
trait ValidatesPortfolioAccess
{
public function validatePortfolioAccess($collection)
{
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
->whereIn('id', $uniquePortfolios)
->count();
if (
$countPortfoliosWithAccess < $uniquePortfolios->count()
) {
throw new \Exception(__('You do not have access to that portfolio.'));
}
}
}
@@ -1,21 +0,0 @@
<?php
namespace App\Imports;
use Exception;
trait ValidatesPortfolioPermissions {
public function validatePortfolioPermissions($collection)
{
$portfolios = auth()->user()->portfolios->pluck('id');
$collection->pluck('portfolio_id')->unique()->each(function($portfolio) use ($portfolios) {
if (!$portfolios->contains($portfolio)) {
throw new Exception('You do not have permission to access that portfolio.');
}
});
}
}
@@ -1,7 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -9,30 +15,28 @@ use Tschucki\Alphavantage\Facades\Alphavantage;
class AlphaVantageMarketData implements MarketDataInterface class AlphaVantageMarketData implements MarketDataInterface
{ {
public function exists(String $symbol): Bool public function exists(string $symbol): bool
{ {
return $this->quote($symbol)->isNotEmpty(); return (bool) $this->quote($symbol);
} }
public function quote(String $symbol): Collection public function quote(string $symbol): Quote
{ {
$quote = Alphavantage::core()->quoteEndpoint($symbol); $quote = Alphavantage::core()->quoteEndpoint($symbol);
$quote = Arr::get($quote, 'Global Quote', []); $quote = Arr::get($quote, 'Global Quote', []);
$fundamental = cache()->tags(['quote', 'alpha-vantage', $symbol])->remember( $fundamental = cache()->remember(
'symbol-'.$symbol, 'av-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol) {
return Alphavantage::fundamentals()->overview($symbol); return Alphavantage::fundamentals()->overview($symbol);
} }
); );
if (empty($fundamental)) return collect(); return new Quote([
return collect([
'name' => Arr::get($fundamental, 'Name'), 'name' => Arr::get($fundamental, 'Name'),
'symbol' => Arr::get($fundamental, 'Symbol'), 'symbol' => $symbol,
'market_value' => Arr::get($quote, '05. price'), 'market_value' => Arr::get($quote, '05. price'),
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'), 'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'), 'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
@@ -45,73 +49,71 @@ class AlphaVantageMarketData implements MarketDataInterface
: null, : null,
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None' 'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
? Arr::get($fundamental, 'DividendYield') ? Arr::get($fundamental, 'DividendYield')
: null : null,
]); ]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
$dividends = Alphavantage::fundamentals()->dividends($symbol); $dividends = Alphavantage::fundamentals()->dividends($symbol);
$dividends = Arr::get($dividends, 'data', []); $dividends = Arr::get($dividends, 'data', []);
return collect($dividends) return collect($dividends)
->filter(function($dividend) use ($startDate, $endDate) { ->filter(function ($dividend) use ($startDate, $endDate) {
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate); return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
}) })
->map(function($dividend) use ($symbol) { ->map(function ($dividend) use ($symbol) {
return [ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')) 'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
->format('Y-m-d H:i:s'), 'dividend_amount' => Arr::get($dividend, 'amount'),
'dividend_amount' => Arr::get($dividend, 'amount'), ]);
]; });
});
} }
public function splits(String $symbol, $startDate, $endDate): Collection public function splits(string $symbol, $startDate, $endDate): Collection
{ {
$splits = Alphavantage::fundamentals()->splits($symbol); $splits = Alphavantage::fundamentals()->splits($symbol);
$splits = Arr::get($splits, 'data', []); $splits = Arr::get($splits, 'data', []);
return collect($splits) return collect($splits)
->filter(function($split) use ($startDate, $endDate) { ->filter(function ($split) use ($startDate, $endDate) {
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate); return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
}) })
->map(function($split) use ($symbol) { ->map(function ($split) use ($symbol) {
return [ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'effective_date')) 'date' => Carbon::parse(Arr::get($split, 'effective_date')),
->format('Y-m-d H:i:s'), 'split_amount' => Arr::get($split, 'split_factor'),
'split_amount' => Arr::get($split, 'split_factor'), ]);
]; });
});
} }
public function history(String $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
$history = Alphavantage::timeSeries()->daily($symbol, 'full'); $history = Alphavantage::timeSeries()->daily($symbol, 'full');
$history = Arr::get($history, 'Time Series (Daily)', []); $history = Arr::get($history, 'Time Series (Daily)', []);
return collect($history) return collect($history)
->filter(function ($history, $date) use ($startDate, $endDate) { ->filter(function ($history, $date) use ($startDate, $endDate) {
return Carbon::parse($date)->between($startDate, $endDate); return Carbon::parse($date)->between($startDate, $endDate);
}) })
->mapWithKeys(function($history, $date) use ($symbol) { ->mapWithKeys(function ($history, $date) use ($symbol) {
$date = Carbon::parse($date)->format('Y-m-d'); $date = Carbon::parse($date)->format('Y-m-d');
return [ $date => [ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => (float) Arr::get($history, '4. close') 'close' => Arr::get($history, '4. close'),
]]; ])];
}); });
} }
} }
+32 -26
View File
@@ -1,22 +1,28 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class FakeMarketData implements MarketDataInterface class FakeMarketData implements MarketDataInterface
{ {
public function exists(String $symbol): Bool public function exists(string $symbol): bool
{ {
return true; return true;
} }
public function quote(String $symbol): Collection public function quote(string $symbol): Quote
{ {
return collect([ return new Quote([
'name' => 'ACME Company Ltd', 'name' => 'ACME Company Ltd',
'symbol' => $symbol, 'symbol' => $symbol,
'market_value' => 230.19, 'market_value' => 230.19,
@@ -27,59 +33,59 @@ class FakeMarketData implements MarketDataInterface
'market_cap' => 9800700600, 'market_cap' => 9800700600,
'book_value' => 4.7, 'book_value' => 4.7,
'last_dividend_date' => now()->subDays(45), 'last_dividend_date' => now()->subDays(45),
'dividend_yield' => .033 'dividend_yield' => 0.033,
]); ]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
return collect([ return collect([
[ new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(3)->format('Y-m-d H:i:s'), 'date' => now()->subMonths(3),
'dividend_amount' => 2.11, 'dividend_amount' => 2.11,
], ]),
[ new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(6)->format('Y-m-d H:i:s'), 'date' => now()->subMonths(6),
'dividend_amount' => 1.89, 'dividend_amount' => 1.89,
], ]),
[ new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(9)->format('Y-m-d H:i:s'), 'date' => now()->subMonths(9),
'dividend_amount' => 0.95, 'dividend_amount' => 0.95,
], ]),
]); ]);
} }
public function splits(String $symbol, $startDate, $endDate): Collection public function splits(string $symbol, $startDate, $endDate): Collection
{ {
return collect([ return collect([
[ new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => now()->subMonths(36)->format('Y-m-d H:i:s'), 'date' => now()->subMonths(36),
'split_amount' => 10, 'split_amount' => 10,
], ]),
]); ]);
} }
public function history(String $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true); $numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
for ($i = 0; $i < $numDays; $i++) { for ($i = 0; $i < $numDays; $i++) {
$date = now()->subDays($i)->format('Y-m-d'); $date = now()->subDays($i)->format('Y-m-d');
$series[$date] = [ $series[$date] = new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => (float) rand(150, 400), 'close' => rand(150, 400),
]; ]);
} }
return collect($series); return collect($series);
} }
} }
@@ -1,26 +1,28 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class FallbackInterface class FallbackInterface
{ {
protected string $latest_error; protected string $latest_error;
public function __call($method, $arguments) public function __call($method, $arguments)
{ {
$providers = explode(',', config('investbrain.provider', 'yahoo')); $providers = explode(',', config('investbrain.provider', 'yahoo'));
foreach ($providers as $provider) { foreach ($providers as $provider) {
$provider = trim($provider); $provider = trim($provider);
try { try {
Log::warning("Calling method {$method} ({$provider})");
if (!in_array($provider, array_keys(config('investbrain.interfaces', [])))) { if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
throw new \Exception("Provider [{$provider}] is not a valid market data interface."); throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
} }
@@ -30,13 +32,20 @@ class FallbackInterface
return app()->make($provider_class_name)->$method(...$arguments); return app()->make($provider_class_name)->$method(...$arguments);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->latest_error = $e->getMessage(); $this->latest_error = $e->getMessage();
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}"); Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
} }
} }
// don't need to throw error if calling exists
if ($method == 'exists') {
// symbol prob just doesn't exist
return false;
}
throw new \Exception("Could not get market data: {$this->latest_error}"); throw new \Exception("Could not get market data: {$this->latest_error}");
} }
} }
+39 -37
View File
@@ -1,7 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -12,38 +18,35 @@ class FinnhubMarketData implements MarketDataInterface
public function __construct() public function __construct()
{ {
$this->client = new \Finnhub\Api\DefaultApi( $this->client = new \Finnhub\Api\DefaultApi(
new \GuzzleHttp\Client(), new \GuzzleHttp\Client,
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key')) \Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
); );
} }
public function exists(String $symbol): Bool
public function exists(string $symbol): bool
{ {
return $this->quote($symbol)->isNotEmpty(); return (bool) $this->quote($symbol);
} }
public function quote($symbol): Collection public function quote(string $symbol): Quote
{ {
$quote = $this->client->quote($symbol); $quote = $this->client->quote($symbol);
$fundamental = cache()->tags(['quote', 'finnhub', $symbol])->remember( $fundamental = cache()->remember(
'symbol-'.$symbol, 'fh-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol) {
return $this->client->companyBasicFinancials($symbol, "all"); return $this->client->companyBasicFinancials($symbol, 'all');
} }
); );
if (empty($fundamental)) return collect(); return new Quote([
return collect([
'name' => Arr::get($fundamental, 'metric.name'), 'name' => Arr::get($fundamental, 'metric.name'),
'symbol' => $symbol, 'symbol' => $symbol,
'market_value' => Arr::get($quote, 'c'), 'market_value' => Arr::get($quote, 'c'),
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'), 'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'), 'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm 'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
@@ -52,55 +55,54 @@ class FinnhubMarketData implements MarketDataInterface
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm 'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm 'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm 'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
]); ]);
} }
public function dividends($symbol, $startDate, $endDate): Collection public function dividends($symbol, $startDate, $endDate): Collection
{ {
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); $dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
return collect($dividends)->map(function($dividend) use ($symbol) { return collect($dividends)->map(function ($dividend) use ($symbol) {
return [ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($dividend, 'date')) 'date' => Carbon::parse(Arr::get($dividend, 'date')),
->format('Y-m-d H:i:s'),
'dividend_amount' => Arr::get($dividend, 'amount'), 'dividend_amount' => Arr::get($dividend, 'amount'),
]; ]);
}); });
} }
public function splits($symbol, $startDate, $endDate): Collection public function splits($symbol, $startDate, $endDate): Collection
{ {
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d')); $splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
return collect($splits)->map(function($split) use ($symbol) { return collect($splits)->map(function ($split) use ($symbol) {
return [ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => Carbon::parse(Arr::get($split, 'date')) 'date' => Carbon::parse(Arr::get($split, 'date')),
->format('Y-m-d H:i:s'),
'split_amount' => Arr::get($split, 'toFactor') / Arr::get($split, 'fromFactor'), 'split_amount' => Arr::get($split, 'toFactor') / Arr::get($split, 'fromFactor'),
]; ]);
}); });
} }
public function history($symbol, $startDate, $endDate): Collection public function history($symbol, $startDate, $endDate): Collection
{ {
$history = $this->client->stockCandles($symbol, "D", $startDate->timestamp, $endDate->timestamp); $history = $this->client->stockCandles($symbol, 'D', $startDate->timestamp, $endDate->timestamp);
$timestamps = Arr::get($history, 't', []); $timestamps = Arr::get($history, 't', []);
$closes = Arr::get($history, 'c', []); $closes = Arr::get($history, 'c', []);
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) { return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d'); $date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
return [ $date => [
return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => (float) $closes[$index], 'close' => $closes[$index],
]]; ])];
}); });
} }
} }
@@ -1,59 +1,36 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Quote;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
interface MarketDataInterface interface MarketDataInterface
{ {
/** /**
* Does this symbol actually exist? * Does this symbol actually exist?
*
* @param String $symbol
*
* @return Bool
*/ */
public function exists(String $symbol): Bool; public function exists(string $symbol): bool;
/** /**
* Get quote data * Get quote data
*
* @param String $symbol
*
* @return Collection
*/ */
public function quote(String $symbol): Collection; public function quote(string $symbol): Quote;
/** /**
* Get dividend data * Get dividend data
*
* @param String $symbol
* @param \DateTimeInterface $startDate
* @param \DateTimeInterface $endDate
*
* @return Collection
*/ */
public function dividends(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection; public function dividends(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
/** /**
* Get split data * Get split data
*
* @param String $symbol
* @param \DateTimeInterface $startDate
* @param \DateTimeInterface $endDate
*
* @return Collection
*/ */
public function splits(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection; public function splits(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
/** /**
* Get historical close data * Get historical close data
*
* @param String $symbol
* @param \DateTimeInterface $startDate
* @param \DateTimeInterface $endDate
*
* @return Collection
*/ */
public function history(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection; public function history(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
} }
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use DateTime;
use Illuminate\Support\Carbon;
class Dividend extends MarketDataType
{
public function setSymbol(string $symbol): self
{
$this->items['symbol'] = $symbol;
return $this;
}
public function getSymbol(): string
{
return $this->items['symbol'] ?? '';
}
public function setDividendAmount($dividendAmount): self
{
$this->items['dividend_amount'] = (float) $dividendAmount;
return $this;
}
public function getDividendAmount(): float
{
return $this->items['dividend_amount'] ?? 0.0;
}
public function setDate(string|DateTime $date): self
{
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this;
}
public function getDate(): ?DateTime
{
return $this->items['date'] ?? null;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class MarketDataType extends Collection
{
public function __construct($items = [])
{
foreach ($this->getArrayableItems($items) as $key => $value) {
$this->{$key} = $value;
}
}
public function toArray()
{
return $this->items;
}
public function __set($key, $value)
{
$this->{'set'.Str::studly($key)}($value);
}
public function __get($key)
{
return $this->items[$key] ?? null;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use DateTime;
use Illuminate\Support\Carbon;
class Ohlc extends MarketDataType
{
public function setSymbol(string $symbol): self
{
$this->items['symbol'] = $symbol;
return $this;
}
public function getSymbol(): string
{
return $this->items['symbol'] ?? '';
}
public function setOpen($open): self
{
$this->items['open'] = (float) $open;
return $this;
}
public function getOpen(): float
{
return $this->items['open'] ?? 0.0;
}
public function setHigh($high): self
{
$this->items['high'] = (float) $high;
return $this;
}
public function getHigh(): float
{
return $this->items['high'] ?? 0.0;
}
public function setLow($low): self
{
$this->items['low'] = (float) $low;
return $this;
}
public function getLow(): float
{
return $this->items['low'] ?? 0.0;
}
public function setClose($close): self
{
$this->items['close'] = (float) $close;
return $this;
}
public function getClose(): float
{
return $this->items['close'] ?? 0.0;
}
public function setDate(string|DateTime $date): self
{
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this;
}
public function getDate(): ?DateTime
{
return $this->items['date'] ?? null;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use DateTime;
use Illuminate\Support\Carbon;
class Quote extends MarketDataType
{
public function setName(string $name): self
{
$this->items['name'] = (string) $name;
return $this;
}
public function getName(): string
{
return $this->items['name'] ?? '';
}
public function setSymbol(string $symbol): self
{
$this->items['symbol'] = (string) $symbol;
return $this;
}
public function getSymbol(): string
{
return $this->items['symbol'] ?? '';
}
public function setMarketValue($marketValue): self
{
$this->items['market_value'] = (float) $marketValue;
return $this;
}
public function getMarketValue(): float
{
return $this->items['market_value'] ?? 0.0;
}
public function setFiftyTwoWeekHigh($high): self
{
$this->items['fifty_two_week_high'] = (float) $high;
return $this;
}
public function getFiftyTwoWeekHigh(): float
{
return $this->items['fifty_two_week_high'] ?? 0.0;
}
public function setFiftyTwoWeekLow($low): self
{
$this->items['fifty_two_week_low'] = (float) $low;
return $this;
}
public function getFiftyTwoWeekLow(): float
{
return $this->items['fifty_two_week_low'] ?? 0.0;
}
public function setForwardPE($pe): self
{
$this->items['forward_pe'] = (float) $pe;
return $this;
}
public function getForwardPE(): float
{
return $this->items['forward_pe'] ?? 0.0;
}
public function setTrailingPE($pe): self
{
$this->items['trailing_pe'] = (float) $pe;
return $this;
}
public function getTrailingPE(): float
{
return $this->items['trailing_pe'] ?? 0.0;
}
public function setMarketCap($cap): self
{
$this->items['market_cap'] = (int) $cap;
return $this;
}
public function getMarketCap(): int
{
return $this->items['market_cap'] ?? 0;
}
public function setBookValue($value): self
{
$this->items['book_value'] = (float) $value;
return $this;
}
public function getBookValue(): float
{
return $this->items['book_value'] ?? 0.0;
}
public function setLastDividendDate(mixed $date): self
{
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
return $this;
}
public function getLastDividendDate(): ?DateTime
{
return $this->items['last_dividend_date'] ?? null;
}
public function setDividendYield($yield): self
{
$this->items['dividend_yield'] = (float) $yield;
return $this;
}
public function getDividendYield(): float
{
return $this->items['dividend_yield'] ?? 0.0;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Interfaces\MarketData\Types;
use DateTime;
use Illuminate\Support\Carbon;
class Split extends MarketDataType
{
public function setSymbol(string $symbol): self
{
$this->items['symbol'] = $symbol;
return $this;
}
public function getSymbol(): string
{
return $this->items['symbol'] ?? '';
}
public function setSplitAmount($splitAmount): self
{
$this->items['split_amount'] = (float) $splitAmount;
return $this;
}
public function getSplitAmount(): float
{
return $this->items['split_amount'] ?? 0.0;
}
public function setDate(string|DateTime $date): self
{
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
return $this;
}
public function getDate(): ?DateTime
{
return $this->items['date'] ?? null;
}
}
+50 -45
View File
@@ -1,7 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Interfaces\MarketData; namespace App\Interfaces\MarketData;
use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Scheb\YahooFinanceApi\ApiClient; use Scheb\YahooFinanceApi\ApiClient;
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance; use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
@@ -10,82 +16,81 @@ class YahooMarketData implements MarketDataInterface
{ {
public ApiClient $client; public ApiClient $client;
public function __construct() { public function __construct()
{
// create yahoo finance client factory // create yahoo finance client factory
$this->client = YahooFinance::createApiClient(); $this->client = YahooFinance::createApiClient();
} }
public function exists(String $symbol): Bool public function exists(string $symbol): bool
{ {
return $this->quote($symbol)->isNotEmpty(); return (bool) $this->quote($symbol);
} }
public function quote(String $symbol): Collection public function quote(string $symbol): Quote
{ {
$quote = $this->client->getQuote($symbol); $quote = $this->client->getQuote($symbol);
if (empty($quote)) return collect(); return new Quote([
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
return collect([ 'symbol' => $symbol,
'name' => $quote->getLongName() ?? $quote->getShortName(), 'market_value' => $quote?->getRegularMarketPrice(),
'symbol' => $quote->getSymbol(), 'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
'market_value' => $quote->getRegularMarketPrice(), 'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
'fifty_two_week_high' => $quote->getFiftyTwoWeekHigh(), 'forward_pe' => $quote?->getForwardPE(),
'fifty_two_week_low' => $quote->getFiftyTwoWeekLow(), 'trailing_pe' => $quote?->getTrailingPE(),
'forward_pe' => $quote->getForwardPE(), 'market_cap' => $quote?->getMarketCap(),
'trailing_pe' => $quote->getTrailingPE(), 'book_value' => $quote?->getBookValue(),
'market_cap' => $quote->getMarketCap(), 'last_dividend_date' => $quote?->getDividendDate(),
'book_value' => $quote->getBookValue(), 'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
'last_dividend_date' => $quote->getDividendDate(),
'dividend_yield' => $quote->getTrailingAnnualDividendYield() * 100
]); ]);
} }
public function dividends(String $symbol, $startDate, $endDate): Collection public function dividends(string $symbol, $startDate, $endDate): Collection
{ {
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate)) return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
->map(function($dividend) use ($symbol) { ->map(function ($dividend) use ($symbol) {
return [ return new Dividend([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $dividend->getDate()->format('Y-m-d H:i:s'), 'date' => $dividend->getDate(),
'dividend_amount' => $dividend->getDividends(), 'dividend_amount' => $dividend->getDividends(),
]; ]);
}); });
} }
public function splits(String $symbol, $startDate, $endDate): Collection public function splits(string $symbol, $startDate, $endDate): Collection
{ {
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate)) return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
->map(function($split) use ($symbol) { ->map(function ($split) use ($symbol) {
$split_amount = explode(':', $split->getStockSplits()); $split_amount = explode(':', $split->getStockSplits());
return [ return new Split([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $split->getDate()->format('Y-m-d H:i:s'), 'date' => $split->getDate(),
'split_amount' => $split_amount[0] / $split_amount[1], 'split_amount' => $split_amount[0] / $split_amount[1],
]; ]);
}); });
} }
public function history(String $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate)) return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
->mapWithKeys(function($history) use ($symbol) { ->mapWithKeys(function ($history) use ($symbol) {
$date = $history->getDate()->format('Y-m-d'); $date = $history->getDate()->format('Y-m-d');
return [ $date => [ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => (float) $history->getClose(), 'close' => $history->getClose(),
]]; ])];
}); });
} }
} }
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Imports\BackupImport as BackupImportExcel;
use App\Models\BackupImport;
use App\Models\User;
use App\Notifications\ImportFailedNotification;
use App\Notifications\ImportSucceededNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Maatwebsite\Excel\Facades\Excel;
use Throwable;
class BackupImportJob implements ShouldQueue
{
use Queueable;
/**
* The number of times the job may be attempted.
*/
public $tries = 1;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 300;
/**
* Indicate if the job should be marked as failed on timeout.
*
* @var bool
*/
public $failOnTimeout = true;
public User $user;
/**
* Create a new job instance.
*/
public function __construct(
public BackupImport $backupImport
) {
$this->user = User::find($this->backupImport->user_id);
}
/**
* Execute the job.
*/
public function handle(): void
{
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
$this->user->notify(new ImportSucceededNotification);
}
/**
* Handle a job failure.
*/
public function failed(?Throwable $e): void
{
$this->backupImport->update([
'status' => 'failed',
'message' => 'Error: '.substr($e->getMessage(), 0, 220),
'has_errors' => true,
'completed_at' => now(),
]);
$this->user->notify(new ImportFailedNotification($e->getMessage()));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class AiChat extends Model
{
use HasUuids;
protected $fillable = [
'role',
'content',
];
protected $hidden = [];
protected static function boot()
{
parent::boot();
static::creating(function ($chat) {
$chat->user_id = auth()->user()->id;
});
}
public function user()
{
return $this->belongsTo(User::class);
}
public function chatable()
{
return $this->morphTo();
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Jobs\BackupImportJob;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class BackupImport extends Model
{
use HasUuids;
protected $table = 'backup_import_jobs';
protected $fillable = [
'user_id',
'path',
'status', // pending, in_progress, success, failed
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
'has_errors',
'completed_at',
];
protected static function boot()
{
parent::boot();
static::creating(function ($import) {
$import->status = 'pending';
$import->message = __('Import starting...');
});
static::created(function ($import) {
BackupImportJob::dispatch($import);
});
}
protected $hidden = [];
protected $appends = [];
protected function casts(): array
{
return [
'has_errors' => 'boolean',
'completed_at' => 'datetime',
];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ConnectedAccount extends Model
{
use HasFactory;
use HasTimestamps;
use HasUuids;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'provider',
'provider_id',
'token',
'secret',
'refresh_token',
'expires_at',
];
protected $with = [
'user',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* Get user of the connected account.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+15 -6
View File
@@ -1,14 +1,16 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Traits\HasCompositePrimaryKey; use App\Traits\HasCompositePrimaryKey;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DailyChange extends Model class DailyChange extends Model
{ {
use HasFactory, HasCompositePrimaryKey; use HasCompositePrimaryKey, HasFactory;
public $timestamps = false; public $timestamps = false;
@@ -32,21 +34,28 @@ class DailyChange extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
]; ];
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio)
{ {
return $query->where('portfolio_id', $portfolio); return $query->where('portfolio_id', $portfolio);
} }
public function scopeMyDailyChanges() public function scopeMyDailyChanges()
{ {
return $this->whereHas('portfolio', function ($query) { return $this->whereHas('portfolio', function ($query) {
$query->whereHas('users', function ($query) { $query->whereHas('users', function ($query) {
$query->where('id', auth()->id()); return $query->where('id', auth()->id());
}); });
}); });
} }
public function scopeWithoutWishlists($query)
{
return $query->whereHas('portfolio', function ($query) {
$query->where('portfolios.wishlist', 0);
});
}
public function portfolio() public function portfolio()
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
+71 -39
View File
@@ -1,15 +1,15 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Holding;
use App\Models\MarketData;
use App\Models\Transaction;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class Dividend extends Model class Dividend extends Model
{ {
@@ -26,18 +26,21 @@ class Dividend extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
'last_date' => 'datetime', 'last_dividend_update' => 'datetime',
]; ];
public function marketData() { public function marketData()
{
return $this->belongsTo(MarketData::class, 'symbol', 'symbol'); return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
} }
public function holdings() { public function holdings()
{
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() { public function transactions()
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol'); return $this->hasMany(Transaction::class, 'symbol', 'symbol');
} }
@@ -48,26 +51,29 @@ class Dividend extends Model
/** /**
* Grab new dividend data * Grab new dividend data
*
* @param string $symbol
* @return void
*/ */
public static function refreshDividendData(string $symbol) public static function refreshDividendData(string $symbol): void
{ {
$dividends_meta = self::where(['symbol' => $symbol]) $dividends_meta = self::where(['symbol' => $symbol])
->selectRaw('COUNT(symbol) as total_dividends') ->selectRaw('COUNT(symbol) as total_dividends')
->selectRaw('MAX(date) as last_date') ->selectRaw('MAX(created_at) as last_dividend_update')
->get() ->get()
->first(); ->first();
// assume we need to populate ALL dividend data // assume we need to populate ALL dividend data
$start_date = new \DateTime('@0'); $start_date = new Carbon('@0');
$end_date = now(); $end_date = now();
// nope, refresh forward looking only // nope, refresh forward looking only
if ( $dividends_meta->total_dividends ) { if ($dividends_meta->total_dividends) {
$start_date = $dividends_meta->last_date->addHours(48); $start_date = $dividends_meta->last_dividend_update->addHours(24);
}
// skip refresh if there's already recent data
if ($start_date->greaterThan($end_date)) {
return;
} }
// get some data // get some data
@@ -78,7 +84,7 @@ class Dividend extends Model
// ah, we found some dividends... // ah, we found some dividends...
if ($dividend_data->isNotEmpty()) { if ($dividend_data->isNotEmpty()) {
// create mass insert // create mass insert
foreach ($dividend_data as $index => $dividend){ foreach ($dividend_data as $index => $dividend) {
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]]; $dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
} }
@@ -86,24 +92,25 @@ class Dividend extends Model
(new self)->insert($dividend_data->toArray()); (new self)->insert($dividend_data->toArray());
// sync to holdings // sync to holdings
self::syncHoldings($dividend_data); self::syncHoldings($symbol);
// get market data
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
// re-invest dividends
self::reinvestDividends($dividend_data, $market_data);
// sync last dividend amount to market data table // sync last dividend amount to market data table
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
$market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount']; $market_data->last_dividend_amount = $dividend_data->sortByDesc('date')->first()['dividend_amount'];
$market_data->save(); $market_data->save();
} }
return $dividend_data;
} }
public static function syncHoldings($dividend_data): void public static function syncHoldings(string $symbol): void
{ {
$symbol = $dividend_data->last()['symbol'];
// group by holdings // group by holdings
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount']) $dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
->selectRaw(' ->selectRaw('
(COALESCE(CASE WHEN transactions.transaction_type = "BUY" (COALESCE(CASE WHEN transactions.transaction_type = "BUY"
AND date(transactions.date) <= date(dividends.date) AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END, 0) THEN transactions.quantity ELSE 0 END, 0)
@@ -113,21 +120,46 @@ class Dividend extends Model
* dividends.dividend_amount * dividends.dividend_amount
AS total_received AS total_received
') ')
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol') ->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id') ->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
->where('dividends.symbol', $dividend_data->last()['symbol']) ->where('dividends.symbol', $symbol)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received') ->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
->havingRaw('total_received > 0') ->havingRaw('total_received > 0')
->get(); ->get();
// iterate through holdings and update // iterate through holdings and update
Holding::where(['symbol' => $symbol]) Holding::where(['symbol' => $symbol])
->get() ->get()
->each(function ($holding) use ($dividends) { ->each(function ($holding) use ($dividends) {
$holding->update([ $holding->update([
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id) 'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
->sum('total_received') ->sum('total_received'),
]);
});
}
public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
{
// re-invest dividends
Holding::where([
'symbol' => $market_data->symbol,
'reinvest_dividends' => true,
])
->get()
->each(function ($holding) use ($dividend_data, $market_data) {
foreach ($dividend_data as $dividend) {
Transaction::create([
'date' => $dividend['date'],
'portfolio_id' => $holding->portfolio_id,
'symbol' => $holding->symbol,
'transaction_type' => 'BUY',
'reinvested_dividend' => true,
'cost_basis' => 0,
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
]); ]);
}); }
});
} }
} }
+152 -111
View File
@@ -1,16 +1,13 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Split;
use App\Models\Dividend;
use App\Models\Portfolio;
use App\Models\MarketData;
use App\Models\Transaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Holding extends Model class Holding extends Model
{ {
@@ -26,16 +23,13 @@ class Holding extends Model
'realized_gain_dollars', 'realized_gain_dollars',
'dividends_earned', 'dividends_earned',
'splits_synced_at', 'splits_synced_at',
'reinvest_dividends',
]; ];
protected $casts = [ protected $casts = [
'splits_synced_at' => 'datetime', 'splits_synced_at' => 'datetime',
'first_transaction_date' => 'datetime' 'first_transaction_date' => 'datetime',
]; 'reinvest_dividends' => 'boolean',
protected $attributes = [
'realized_gain_dollars' => 0,
'dividends_earned' => 0,
]; ];
/** /**
@@ -43,7 +37,7 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function market_data() public function market_data()
{ {
return $this->hasOne(MarketData::class, 'symbol', 'symbol'); return $this->hasOne(MarketData::class, 'symbol', 'symbol');
} }
@@ -53,7 +47,7 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function transactions() public function transactions()
{ {
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC'); return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
} }
@@ -63,35 +57,49 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function dividends() public function dividends()
{ {
return $this->hasMany(Dividend::class, 'symbol', 'symbol') return $this->hasMany(Dividend::class, 'symbol', 'symbol')
->select(['dividends.symbol','dividends.date','dividends.dividend_amount']) ->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
->selectRaw("SUM( ->selectRaw("SUM(
CASE WHEN transaction_type = 'BUY' CASE WHEN transaction_type = 'BUY'
AND transactions.symbol = dividends.symbol AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id' AND transactions.portfolio_id = '$this->portfolio_id'
AND dividends.date >= transactions.date AND date(dividends.date) >= date(transactions.date)
THEN transactions.quantity THEN transactions.quantity
ELSE 0 END ELSE 0 END
) AS purchased") ) AS purchased")
->selectRaw("SUM( ->selectRaw("SUM(
CASE WHEN transaction_type = 'SELL' CASE WHEN transaction_type = 'SELL'
AND transactions.symbol = dividends.symbol AND transactions.symbol = dividends.symbol
AND transactions.portfolio_id = '$this->portfolio_id' AND transactions.portfolio_id = '$this->portfolio_id'
AND dividends.date >= transactions.date AND date(dividends.date) >= date(transactions.date)
THEN transactions.quantity THEN transactions.quantity
ELSE 0 END ELSE 0 END
) AS sold") ) AS sold")
->join('transactions', 'transactions.symbol', 'dividends.symbol') ->selectRaw("SUM(
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount']) (CASE WHEN transaction_type = 'BUY'
->orderBy('dividends.date', 'DESC') AND transactions.symbol = dividends.symbol
->where('dividends.date', '>=', function ($query) { AND transactions.portfolio_id = '$this->portfolio_id'
$query->selectRaw('min(transactions.date)') AND date(transactions.date) <= date(dividends.date)
->from('transactions') THEN transactions.quantity ELSE 0 END
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'") - CASE WHEN transaction_type = 'SELL'
->whereRaw("transactions.symbol = '$this->symbol'"); AND transactions.symbol = dividends.symbol
}); AND transactions.portfolio_id = '$this->portfolio_id'
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END)
* dividends.dividend_amount
) AS total_received")
->join('transactions', 'transactions.symbol', 'dividends.symbol')
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
->orderBy('dividends.date', 'DESC')
->where('dividends.date', '>=', function ($query) {
$query->selectRaw('min(transactions.date)')
->from('transactions')
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
->whereRaw("transactions.symbol = '$this->symbol'");
})
->having('total_received', '>', 0);
} }
/** /**
@@ -99,7 +107,7 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function portfolio() public function portfolio()
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
} }
@@ -109,27 +117,37 @@ class Holding extends Model
* *
* @return void * @return void
*/ */
public function splits() public function splits()
{ {
return $this->hasMany(Split::class, 'symbol', 'symbol') return $this->hasMany(Split::class, 'symbol', 'symbol')
->orderBy('date', 'DESC'); ->orderBy('date', 'DESC');
} }
/**
* Related chats for holding
*
* @return void
*/
public function chats()
{
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
}
public function scopeWithMarketData($query) public function scopeWithMarketData($query)
{ {
return $query->withAggregate('market_data', 'name') return $query->withAggregate('market_data', 'name')
->withAggregate('market_data', 'market_value') ->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'updated_at')
->join('market_data', 'holdings.symbol', 'market_data.symbol'); ->join('market_data', 'holdings.symbol', 'market_data.symbol');
} }
public function scopeWithPerformance($query) public function scopeWithPerformance($query)
{ {
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value') return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars') ->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis), 0) AS market_gain_percent'); ->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100, 0) AS market_gain_percent');
} }
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio)
@@ -142,93 +160,100 @@ class Holding extends Model
return $query->where('holdings.symbol', $symbol); return $query->where('holdings.symbol', $symbol);
} }
public function scopeWithoutWishlists($query) { public function scopeWithoutWishlists($query)
return $query->join('portfolios', 'portfolios.id', 'holdings.portfolio_id')
->where('portfolios.wishlist', 0);
}
public function scopeMyHoldings($query)
{ {
return $query->whereHas('portfolio', function($query) { return $query->whereHas('portfolio', function ($query) {
$query->whereRelation('users', 'id', auth()->user()->id); $query->where('portfolios.wishlist', 0);
}); });
} }
public function scopeWithPortfolioMetrics($query) public function scopeMyHoldings($query, $userId = null)
{
return $query->whereHas('portfolio', function ($query) use ($userId) {
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
});
}
public function scopeWithPortfolioMetrics($query)
{ {
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned') return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars') ->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value') ->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis') ->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars') ->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent') // ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol'); ->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
} }
public function syncTransactionsAndDividends() public function syncTransactionsAndDividends()
{ {
// pull existing transaction data // pull existing transaction data
$query = Transaction::where([ $query = Transaction::where([
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'symbol' => $this->symbol,
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`') ])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`')
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`') ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`')
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `cost_basis`') ->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `total_cost_basis`')
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN ((sale_price - cost_basis) * quantity) ELSE 0 END) AS `realized_gains`') ->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`')
->first(); ->first();
$total_quantity = round($query->qty_purchases - $query->qty_sales, 5); $total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
$average_cost_basis = ( $average_cost_basis = (
$query->qty_purchases > 0 $query->qty_purchases > 0
&& $total_quantity > 0 && $total_quantity > 0
) )
? $query->cost_basis / $query->qty_purchases ? $query->total_cost_basis / $query->qty_purchases
: 0; : 0;
// pull dividend data joined with holdings/transactions
$dividends = Dividend::select('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount')
->selectRaw('
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END, 0)
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
AND date(transactions.date) <= date(dividends.date)
THEN transactions.quantity ELSE 0 END, 0))
* dividends.dividend_amount
AS total_received
')
->join('transactions', 'transactions.symbol', 'dividends.symbol')
->join('holdings', 'transactions.portfolio_id', 'holdings.portfolio_id')
->where('dividends.symbol', $this->symbol)
->where('transactions.portfolio_id', $this->portfolio_id)
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
->get();
// update holding // update holding
$this->fill([ $this->fill([
'quantity' => $total_quantity, 'quantity' => $total_quantity,
'average_cost_basis' => $average_cost_basis, 'average_cost_basis' => $average_cost_basis,
'total_cost_basis' => $total_quantity * $average_cost_basis, 'total_cost_basis' => $total_quantity * $average_cost_basis,
'realized_gain_dollars' => $query->realized_gains, 'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
'dividends_earned' => $dividends->sum('total_received') ? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
: 0,
'dividends_earned' => $this->dividends->sum('total_received'),
]); ]);
$this->save(); $this->save();
} }
public function dailyPerformance( public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
\Illuminate\Support\Carbon $start_date = null, {
\Illuminate\Support\Carbon $end_date = null, if ($date == null) {
) { $date = now();
if ($start_date == null) $start_date = now(); }
if ($end_date == null) $end_date = now();
$date_interval = "DATE_ADD(date, INTERVAL 1 DAY)"; $transactions = $this->transactions->where('date', '<=', $date);
$purchases = $transactions->where('transaction_type', 'BUY')->sum('quantity');
$sales = $transactions->where('transaction_type', 'SELL')->sum('quantity');
return $purchases - $sales;
}
public function dailyPerformance(
?\Illuminate\Support\Carbon $start_date = null,
?\Illuminate\Support\Carbon $end_date = null,
) {
if ($start_date == null) {
$start_date = now();
}
if ($end_date == null) {
$end_date = now();
}
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
if (config('database.default') === 'sqlite') { if (config('database.default') === 'sqlite') {
$date_interval = "date(date, '+1 day')"; $date_interval = "date(date, '+1 day')";
} else {
DB::statement('SET cte_max_recursion_depth=1000000;');
} }
return DB::table(DB::raw("( return DB::table(DB::raw("(
@@ -243,17 +268,19 @@ class Holding extends Model
FROM date_series FROM date_series
) as date_series") ) as date_series")
) )
->select([ ->select([
'date_series.date', 'date_series.date',
DB::raw(" DB::raw("
ROUND(
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0) AS `owned` COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned`
"), "),
DB::raw(" DB::raw("
COALESCE(CASE COALESCE(CASE
WHEN ( WHEN (
ROUND(
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0) COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3)
) = 0 THEN 0 ) = 0 THEN 0
ELSE SUM(CASE ELSE SUM(CASE
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
@@ -261,16 +288,30 @@ class Holding extends Model
END) END)
END, 0) AS cost_basis END, 0) AS cost_basis
"), "),
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`") DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`"),
]) ])
->leftJoin('transactions', function ($join) { ->leftJoin('transactions', function ($join) {
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date') $join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
->where('transactions.symbol', '=', $this->symbol) ->where('transactions.symbol', '=', $this->symbol)
->where('transactions.portfolio_id', '=', $this->portfolio_id); ->where('transactions.portfolio_id', '=', $this->portfolio_id);
}) })
->groupBy('date_series.date') ->groupBy('date_series.date')
->orderBy('date_series.date') ->orderBy('date_series.date')
->get() ->get()
->keyBy('date'); ->keyBy('date');
} }
}
public function getFormattedTransactions()
{
$formattedTransactions = '';
foreach ($this->transactions->sortByDesc('date') as $transaction) {
$formattedTransactions .= ' * '.$transaction->date->format('Y-m-d')
.' '.$transaction->transaction_type
.' '.$transaction->quantity
.' @ '.$transaction->cost_basis
." each \n\n";
}
return $formattedTransactions;
}
}
+14 -9
View File
@@ -1,17 +1,21 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MarketData extends Model class MarketData extends Model
{ {
use HasFactory; use HasFactory;
protected $primaryKey = 'symbol'; protected $primaryKey = 'symbol';
protected $keyType = 'string'; protected $keyType = 'string';
public $incrementing = false; public $incrementing = false;
protected $fillable = [ protected $fillable = [
@@ -25,7 +29,7 @@ class MarketData extends Model
'market_cap', 'market_cap',
'book_value', 'book_value',
'last_dividend_date', 'last_dividend_date',
'dividend_yield' 'dividend_yield',
]; ];
protected $casts = [ protected $casts = [
@@ -37,10 +41,10 @@ class MarketData extends Model
'trailing_pe' => 'float', 'trailing_pe' => 'float',
'market_cap' => 'float', 'market_cap' => 'float',
'book_value' => 'float', 'book_value' => 'float',
'dividend_yield' => 'float' 'dividend_yield' => 'float',
]; ];
public function holdings() public function holdings()
{ {
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
@@ -50,19 +54,20 @@ class MarketData extends Model
return $query->where('symbol', $symbol); return $query->where('symbol', $symbol);
} }
public static function getMarketData($symbol) public static function getMarketData($symbol, $force = false)
{ {
$market_data = self::firstOrNew([ $market_data = self::firstOrNew([
'symbol' => $symbol 'symbol' => $symbol,
]); ]);
// check if new or stale // check if new or stale
if ( if (
!$market_data->exists $force
|| ! $market_data->exists
|| is_null($market_data->updated_at) || is_null($market_data->updated_at)
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh') || $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
) { ) {
// get quote // get quote
$quote = app(MarketDataInterface::class)->quote($symbol); $quote = app(MarketDataInterface::class)->quote($symbol);
@@ -75,4 +80,4 @@ class MarketData extends Model
return $market_data; return $market_data;
} }
} }
+187 -58
View File
@@ -1,13 +1,19 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use App\Notifications\InvitedOnboardingNotification;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Portfolio extends Model class Portfolio extends Model
{ {
@@ -20,34 +26,36 @@ class Portfolio extends Model
'wishlist', 'wishlist',
]; ];
public static ?string $owner_id = null;
protected static function boot() protected static function boot()
{ {
parent::boot(); parent::boot();
static::saved(function ($model) {
self::syncUsers($model); static::saved(function ($portfolio) {
self::ensurePortfolioHasOwner($portfolio);
}); });
} }
protected $hidden = []; protected $hidden = [];
protected $casts = [ protected $casts = [
'wishlist' => 'boolean' 'wishlist' => 'boolean',
]; ];
protected $with = ['users', 'transactions']; protected $with = ['users', 'transactions'];
public function users() public function users()
{ {
return $this->belongsToMany(User::class)->withPivot('owner'); return $this->belongsToMany(User::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']);
} }
public function holdings() public function holdings()
{ {
return $this->hasMany(Holding::class, 'portfolio_id') return $this->hasMany(Holding::class, 'portfolio_id')
->withMarketData() ->withMarketData()
->withPerformance(); ->withPerformance();
} }
public function transactions() public function transactions()
@@ -60,91 +68,137 @@ class Portfolio extends Model
return $this->hasMany(DailyChange::class); return $this->hasMany(DailyChange::class);
} }
public function scopeMyPortfolios() /**
* Related chats for portfolio
*
* @return void
*/
public function chats()
{
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
}
public function scopeMyPortfolios()
{ {
return $this->whereHas('users', function ($query) { return $this->whereHas('users', function ($query) {
$query->where('user_id', auth()->user()->id); $query->where('user_id', auth()->user()->id);
}); });
} }
public function scopeWithoutWishlists() public function scopeFullAccess($query, $user_id = null)
{
return $query->whereHas('users', function ($query) use ($user_id) {
$query->where('user_id', $user_id ?? auth()->user()->id)
->where(function ($query) {
$query->where('full_access', true)
->orWhere('owner', true);
});
});
}
public function scopeWithoutWishlists()
{ {
return $this->where(['wishlist' => false]); return $this->where(['wishlist' => false]);
} }
public function getOwnerIdAttribute() public function setOwnerIdAttribute($value)
{ {
return $this->users()->firstWhere('owner', 1)?->id; // enable queued jobs to create portfolios with owners
if (! auth()->user()?->id && ! $this->owner_id) {
static::$owner_id = $value;
}
} }
public static function syncUsers(self $model) public function getOwnerIdAttribute()
{
return $this->owner?->id;
}
public function getOwnerAttribute()
{
if (! $this->relationLoaded('user')) {
$this->load('users');
}
return $this->users->where('pivot.owner', true)->first();
}
public static function ensurePortfolioHasOwner(self $portfolio)
{ {
// make sure we don't remove owner access // make sure we don't remove owner access
$user_id[$model->owner_id ?? auth()->user()->id] = ['owner' => true]; if (! $portfolio->owner_id) {
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
// // add other users // save
// foreach(request()->users ?? [] as $id) { $portfolio->users()->sync($owner);
// $user_id[$id] = ['owner' => false]; static::$owner_id = null;
// }; }
// save
$model->users()->sync($user_id);
} }
public function syncDailyChanges(): void public function syncDailyChanges(): void
{ {
$holdings = $this->holdings() $holdings = $this->holdings()
->join('transactions', function($join) { ->join('transactions', function ($join) {
$join->on('transactions.symbol', '=', 'holdings.symbol') $join->on('transactions.symbol', '=', 'holdings.symbol')
->where('transactions.portfolio_id', '=', $this->id); ->where('transactions.portfolio_id', '=', $this->id);
}) })
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date ->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
->groupBy(['holdings.symbol', 'holdings.portfolio_id']) ->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
->get(); ->get();
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get(); $dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
$total_performance = []; $total_performance = [];
$holdings->each(function($holding) use (&$total_performance, $dividends) { $holdings->each(function ($holding) use (&$total_performance, $dividends) {
$period = CarbonPeriod::create(
$holding->first_transaction_date,
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
? now()->subDay()
: now()
);
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol)); $holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
return $dividend['date']->format('Y-m-d');
});
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now()); $all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
return $dividend['date']->format('Y-m-d');
});
$dividends_earned = 0; $dividends_earned = 0;
$daily = []; $holding_performance = [];
$all_history->sortBy('date')->each(function ($history, $date) use ($daily_performance, $dividends, &$daily, &$dividends_earned) { foreach ($period as $date) {
$date = $date->format('Y-m-d');
$close = $this->getMostRecentCloseData($all_history, $date);
$close = Arr::get($history, 'close', 0);
$total_market_value = $daily_performance->get($date)->owned * $close; $total_market_value = $daily_performance->get($date)->owned * $close;
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0); $dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
$daily[$date] = [ if (Carbon::parse($date)->isWeekday()) {
'date' => $date, $holding_performance[$date] = [
'portfolio_id' => $this->id, 'date' => $date,
'total_market_value' => $total_market_value, 'portfolio_id' => $this->id,
'total_cost_basis' => $daily_performance->get($date)->cost_basis, 'total_market_value' => $total_market_value,
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis, 'total_cost_basis' => $daily_performance->get($date)->cost_basis,
'realized_gains' => $daily_performance->get($date)->realized_gains, 'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
'total_dividends_earned' => $dividends_earned 'realized_gains' => $daily_performance->get($date)->realized_gains,
]; 'total_dividends_earned' => $dividends_earned,
}); ];
}
}
foreach ($holding_performance as $date => $performance) {
if (Arr::get($total_performance, $date) == null) {
foreach ($daily as $date => $performance) {
if (!isset($total_performance[$date])) {
$total_performance[$date] = $performance; $total_performance[$date] = $performance;
} else { } else {
$total_performance[$date]['total_market_value'] += $performance['total_market_value']; $total_performance[$date]['total_market_value'] += $performance['total_market_value'];
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis']; $total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
$total_performance[$date]['total_gain'] += $performance['total_gain']; $total_performance[$date]['total_gain'] += $performance['total_gain'];
@@ -154,12 +208,87 @@ class Portfolio extends Model
} }
}); });
if (!empty($total_performance)) { if (! empty($total_performance)) {
DB::transaction(function () use ($total_performance) { DB::transaction(function () use ($total_performance) {
$this->daily_change()->delete();
DailyChange::insert($total_performance); $this->daily_change()->upsert(
$total_performance,
['date', 'portfolio_id'],
[
'total_market_value',
'total_cost_basis',
'total_gain',
'realized_gains',
'total_dividends_earned',
]
);
}); });
} }
} }
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
{
$close = Arr::get($history, "$date.close", 0);
if (! $close && $i < $max_attempts) {
$i++;
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
return $this->getMostRecentCloseData($history, $date, $i);
}
return $close;
}
public function getFormattedHoldings()
{
$formattedHoldings = '';
foreach ($this->holdings as $holding) {
$formattedHoldings .= ' * Holding of '.$holding->market_data->name.' ('.$holding->symbol.')'
.'; with '.($holding->quantity > 0 ? $holding->quantity : 'ZERO').' shares'
.'; avg cost basis '.$holding->average_cost_basis
.'; curr market value '.$holding->market_data->market_value
.'; unrealized gains '.$holding->market_gain_dollars
.'; realized gains '.$holding->realized_gain_dollars
.'; dividends earned '.$holding->dividends_earned
."\n\n";
}
return $formattedHoldings;
}
/**
* Share a portfolio with a user
*/
public function share(string $email, bool $fullAccess = false): void
{
$user = User::firstOrCreate([
'email' => $email,
], [
'name' => Str::title(Str::before($email, '@')),
]);
$permissions[$user->id] = [
'full_access' => $fullAccess,
];
$sync = $this->users()->syncWithoutDetaching($permissions);
if (! empty($sync['attached'])) {
foreach ($sync['attached'] as $newUserId) {
User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
}
}
}
/**
* Un-share a portfolio
*/
public function unShare(string $userId): void
{
$this->users()->detach($userId);
}
} }
+38 -36
View File
@@ -1,14 +1,15 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Transaction;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class Split extends Model class Split extends Model
{ {
@@ -28,22 +29,23 @@ class Split extends Model
'last_date' => 'datetime', 'last_date' => 'datetime',
]; ];
public function holdings() { public function holdings()
{
return $this->hasMany(Holding::class, 'symbol', 'symbol'); return $this->hasMany(Holding::class, 'symbol', 'symbol');
} }
public function transactions() { public function transactions()
{
return $this->hasMany(Transaction::class, 'symbol', 'symbol'); return $this->hasMany(Transaction::class, 'symbol', 'symbol');
} }
/** /**
* Grab new split data * Grab new split data
* *
* @param string $symbol * @param \DateTimeInterface|null $start_date
* @param \DateTimeInterface|null $start_date
* @return void * @return void
*/ */
public static function refreshSplitData(string $symbol) public static function refreshSplitData(string $symbol)
{ {
// dates for split data // dates for split data
$splits_meta = self::where(['symbol' => $symbol]) $splits_meta = self::where(['symbol' => $symbol])
@@ -58,9 +60,9 @@ class Split extends Model
// nope, need to populate newer split data // nope, need to populate newer split data
if ($splits_meta->total_splits) { if ($splits_meta->total_splits) {
$start_date = $splits_meta->last_date->addHours(48); $start_date = $splits_meta->last_date->addHours(48);
$end_date = now(); $end_date = now();
} }
// get some data // get some data
@@ -71,10 +73,10 @@ class Split extends Model
if ($split_data->isNotEmpty()) { if ($split_data->isNotEmpty()) {
// insert records // insert records
(new self)->insert($split_data->map(function($split) { (new self)->insert($split_data->map(function ($split) {
return [...$split, ...['id' => Str::uuid()->toString()]]; return [...$split, ...['id' => Str::uuid()->toString()]];
})->toArray()); })->toArray());
} }
// sync to transactions // sync to transactions
@@ -84,39 +86,39 @@ class Split extends Model
/** /**
* Syncs all transactions of symbol with split data * Syncs all transactions of symbol with split data
* *
* @param string $symbol * @param string $symbol
* @return void * @return void
*/ */
public static function syncToTransactions($symbol) public static function syncToTransactions($symbol)
{ {
// get splits joined with matching holdings // get splits joined with matching holdings
$splits = self::select([ $splits = self::select([
'splits.date', 'splits.date',
'splits.symbol', 'splits.symbol',
'splits.split_amount', 'splits.split_amount',
'holdings.portfolio_id' 'holdings.portfolio_id',
]) ])
->where([ ->where([
'splits.symbol' => $symbol, 'splits.symbol' => $symbol,
]) ])
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")')) ->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
->where('holdings.quantity', '>', 0) ->where('holdings.quantity', '>', 0)
->join('holdings', 'splits.symbol', 'holdings.symbol') ->join('holdings', 'splits.symbol', 'holdings.symbol')
->orderBy('splits.date', 'ASC') ->orderBy('splits.date', 'ASC')
->get(); ->get();
foreach($splits as $split) { foreach ($splits as $split) {
// get qty owned when split was issued // get qty owned when split was issued
$qty_owned = Transaction::where([ $qty_owned = Transaction::where([
'symbol' => $split->symbol, 'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id 'portfolio_id' => $split->portfolio_id,
]) ])
->whereDate('transactions.date', '<', $split->date->format('Y-m-d')) ->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) - ->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) -
SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS qty_owned') SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS qty_owned')
->value('qty_owned'); ->value('qty_owned');
if ($qty_owned > 0) { if ($qty_owned > 0) {
Transaction::create([ Transaction::create([
@@ -128,14 +130,14 @@ class Split extends Model
'cost_basis' => 0, 'cost_basis' => 0,
'split' => true, 'split' => true,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now() 'updated_at' => now(),
]); ]);
Holding::where([ Holding::where([
'symbol' => $split->symbol, 'symbol' => $split->symbol,
'portfolio_id' => $split->portfolio_id 'portfolio_id' => $split->portfolio_id,
])->update([ ])->update([
'splits_synced_at' => now() 'splits_synced_at' => now(),
]); ]);
} }
} }
+35 -32
View File
@@ -1,13 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\MarketData; use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr;
class Transaction extends Model class Transaction extends Model
{ {
@@ -23,6 +27,7 @@ class Transaction extends Model
'cost_basis', 'cost_basis',
'sale_price', 'sale_price',
'split', 'split',
'reinvested_dividend',
]; ];
protected $hidden = []; protected $hidden = [];
@@ -30,6 +35,7 @@ class Transaction extends Model
protected $casts = [ protected $casts = [
'date' => 'datetime', 'date' => 'datetime',
'split' => 'boolean', 'split' => 'boolean',
'reinvested_dividend' => 'boolean',
]; ];
protected static function boot() protected static function boot()
@@ -50,14 +56,14 @@ class Transaction extends Model
$transaction->refreshMarketData(); $transaction->refreshMarketData();
cache()->tags(['metrics', $transaction->portfolio_id])->flush(); cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
}); });
static::deleted(function ($transaction) { static::deleted(function ($transaction) {
$transaction->syncToHolding(); $transaction->syncToHolding();
cache()->tags(['metrics', $transaction->portfolio_id])->flush(); cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
}); });
} }
@@ -76,7 +82,7 @@ class Transaction extends Model
* *
* @return void * @return void
*/ */
public function market_data() public function market_data(): HasOne
{ {
return $this->hasOne(MarketData::class, 'symbol', 'symbol'); return $this->hasOne(MarketData::class, 'symbol', 'symbol');
} }
@@ -86,47 +92,47 @@ class Transaction extends Model
* *
* @return void * @return void
*/ */
public function portfolio() public function portfolio(): BelongsTo
{ {
return $this->belongsTo(Portfolio::class); return $this->belongsTo(Portfolio::class);
} }
public function scopeWithMarketData($query) public function scopeWithMarketData($query): Builder
{ {
return $query->withAggregate('market_data', 'name') return $query->withAggregate('market_data', 'name')
->withAggregate('market_data', 'market_value') ->withAggregate('market_data', 'market_value')
->withAggregate('market_data', 'fifty_two_week_low') ->withAggregate('market_data', 'fifty_two_week_low')
->withAggregate('market_data', 'fifty_two_week_high') ->withAggregate('market_data', 'fifty_two_week_high')
->withAggregate('market_data', 'updated_at') ->withAggregate('market_data', 'updated_at')
->join('market_data', 'transactions.symbol', 'market_data.symbol'); ->join('market_data', 'transactions.symbol', 'market_data.symbol');
} }
public function scopePortfolio($query, $portfolio) public function scopePortfolio($query, $portfolio): Builder
{ {
return $query->where('portfolio_id', $portfolio); return $query->where('portfolio_id', $portfolio);
} }
public function scopeSymbol($query, $symbol) public function scopeSymbol($query, $symbol): Builder
{ {
return $query->where('symbol', $symbol); return $query->where('symbol', $symbol);
} }
public function scopeBuy($query) public function scopeBuy($query): Builder
{ {
return $query->where('transaction_type', 'BUY'); return $query->where('transaction_type', 'BUY');
} }
public function scopeSell($query) public function scopeSell($query): Builder
{ {
return $query->where('transaction_type', 'SELL'); return $query->where('transaction_type', 'SELL');
} }
public function scopeBeforeDate($query, $date) public function scopeBeforeDate($query, $date): Builder
{ {
return $query->whereDate('date', '<', $date); return $query->whereDate('date', '<=', $date);
} }
public function scopeMyTransactions() public function scopeMyTransactions(): Builder
{ {
return $this->whereHas('portfolio', function ($query) { return $this->whereHas('portfolio', function ($query) {
$query->whereHas('users', function ($query) { $query->whereHas('users', function ($query) {
@@ -135,24 +141,22 @@ class Transaction extends Model
}); });
} }
public function refreshMarketData() public function refreshMarketData(): void
{ {
return MarketData::getMarketData($this->attributes['symbol']); MarketData::getMarketData($this->attributes['symbol']);
} }
/** /**
* Writes average cost basis to a sale transaction * Writes average cost basis to a sale transaction
*
* @return Transaction
*/ */
public function ensureCostBasisIsAddedToSale() public function ensureCostBasisIsAddedToSale(): Transaction
{ {
$average_cost_basis = Transaction::where([ $average_cost_basis = Transaction::where([
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'symbol' => $this->symbol,
'transaction_type' => 'BUY', 'transaction_type' => 'BUY',
])->whereDate('date', '<=', $this->date) ])->whereDate('date', '<=', $this->date)
->average('cost_basis'); ->average('cost_basis');
$this->cost_basis = $average_cost_basis ?? 0; $this->cost_basis = $average_cost_basis ?? 0;
@@ -161,10 +165,9 @@ class Transaction extends Model
/** /**
* Syncs the holding related to this transaction * Syncs the holding related to this transaction
*
* @return void
*/ */
public function syncToHolding() { public function syncToHolding(): void
{
// if symbol name changed, sync previous symbol too // if symbol name changed, sync previous symbol too
if (Arr::has($this->changes, 'symbol')) { if (Arr::has($this->changes, 'symbol')) {
@@ -179,7 +182,7 @@ class Transaction extends Model
// get the holding for a symbol and portfolio (or create one) // get the holding for a symbol and portfolio (or create one)
Holding::firstOrNew([ Holding::firstOrNew([
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol 'symbol' => $this->symbol,
], [ ], [
'portfolio_id' => $this->portfolio_id, 'portfolio_id' => $this->portfolio_id,
'symbol' => $this->symbol, 'symbol' => $this->symbol,
@@ -189,4 +192,4 @@ class Transaction extends Model
'splits_synced_at' => now(), 'splits_synced_at' => now(),
])->syncTransactionsAndDividends(); ])->syncTransactionsAndDividends();
} }
} }
+18 -14
View File
@@ -1,28 +1,31 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Traits\HasConnectedAccounts;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Jetstream\HasProfilePhoto;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
class User extends Authenticatable class User extends Authenticatable implements MustVerifyEmail
{ {
use HasApiTokens; use HasApiTokens;
use HasConnectedAccounts;
use HasFactory; use HasFactory;
use HasProfilePhoto; use HasProfilePhoto;
use HasRelationships;
use HasUuids;
use Notifiable; use Notifiable;
use TwoFactorAuthenticatable; use TwoFactorAuthenticatable;
use HasUuids;
use HasRelationships;
protected $fillable = [ protected $fillable = [
'name', 'name',
@@ -31,6 +34,7 @@ class User extends Authenticatable
]; ];
protected $hidden = [ protected $hidden = [
'admin',
'password', 'password',
'remember_token', 'remember_token',
'two_factor_recovery_codes', 'two_factor_recovery_codes',
@@ -51,7 +55,7 @@ class User extends Authenticatable
public function portfolios() public function portfolios()
{ {
return $this->belongsToMany(Portfolio::class)->withPivot('owner'); return $this->belongsToMany(Portfolio::class)->withPivot(['owner', 'full_access', 'invite_accepted_at']);
} }
public function daily_changes() public function daily_changes()
@@ -63,7 +67,7 @@ class User extends Authenticatable
{ {
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class]) return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
->withMarketData() ->withMarketData()
->withPerformance(); ->withPerformance();
} }
public function transactions(): HasManyDeep public function transactions(): HasManyDeep
@@ -76,6 +80,6 @@ class User extends Authenticatable
WHEN transaction_type = \'SELL\' WHEN transaction_type = \'SELL\'
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0) THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0) ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
END AS gain_dollars'); END AS gain_dollars');
} }
} }
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ImportFailedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public string $errorMessage
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->greeting('Oh no!')
->subject('Your Investbrain import failed!')
->line('Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.')
->action('Try again?', route('import-export'))
->line('**Technical details:**')
->line($this->errorMessage);
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ImportSucceededNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct() {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->greeting('Woot! 🎉')
->subject('Your Investbrain import was successful!')
->line('Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.')
->action('Get Started', route('dashboard'));
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Portfolio;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvitedOnboardingNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public Portfolio $portfolio,
public User $sender,
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
return (new MailMessage)
->replyTo($this->sender->email, $this->sender->name)
->greeting('Hey there! 👋')
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
->action('Get Started', $url)
->line('If you have any questions, you can reply to this email.')
->salutation("See you there,\n".e($this->sender->name));
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\ConnectedAccount;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public string $connected_account_id
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$connected_account = ConnectedAccount::find($this->connected_account_id);
$provider = config("services.$connected_account->provider.name");
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
return (new MailMessage)
->greeting('Welcome back!')
->subject("Connect your $provider account with Investbrain")
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
->action("Connect $provider", $url)
->line('If you do not recognize this activity, we recommend [changing your password]('.route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Portfolio;
use App\Models\User;
class PortfolioPolicy
{
public function readOnly(User $user, Portfolio $portfolio)
{
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
return (bool) $pivot;
}
public function fullAccess(User $user, Portfolio $portfolio)
{
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
}
public function owner(User $user, Portfolio $portfolio)
{
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
return $pivot && $pivot->pivot->owner;
}
}
+4 -1
View File
@@ -1,7 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -22,6 +25,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// JsonResource::withoutWrapping();
} }
} }
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\CreateNewUser;
+16 -6
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Actions\Jetstream\DeleteUser; use App\Actions\Jetstream\DeleteUser;
@@ -25,7 +27,6 @@ class JetstreamServiceProvider extends ServiceProvider
$this->configurePermissions(); $this->configurePermissions();
Jetstream::deleteUsersUsing(DeleteUser::class); Jetstream::deleteUsersUsing(DeleteUser::class);
} }
/** /**
@@ -33,13 +34,22 @@ class JetstreamServiceProvider extends ServiceProvider
*/ */
protected function configurePermissions(): void protected function configurePermissions(): void
{ {
Jetstream::defaultApiTokenPermissions(['read']); Jetstream::defaultApiTokenPermissions([
// 'portfolio:read',
// 'portfolio:write',
// 'holding:read',
// 'holding:write',
// 'transaction:read',
// 'transaction:write',
]);
Jetstream::permissions([ Jetstream::permissions([
'create', // 'Read Portfolios' => 'portfolio:read',
'read', // 'Create Portfolios' => 'portfolio:write',
'update', // 'Read Holdings' => 'holding:read',
'delete', // 'Update Holdings' => 'holding:write',
// 'Read Transactions' => 'transaction:read',
// 'Create Transactions' => 'transaction:write',
]); ]);
} }
} }
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
+23 -20
View File
@@ -1,9 +1,12 @@
<?php <?php
declare(strict_types=1);
namespace App\Rules; namespace App\Rules;
use App\Models\Portfolio; use App\Models\Portfolio;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
class QuantityValidationRule implements ValidationRule class QuantityValidationRule implements ValidationRule
{ {
@@ -13,44 +16,44 @@ class QuantityValidationRule implements ValidationRule
* @return void * @return void
*/ */
public function __construct( public function __construct(
protected Portfolio $portfolio, protected ?Portfolio $portfolio,
protected string $symbol, protected ?string $symbol,
protected string $transactionType, protected ?string $transactionType,
protected string $date protected string|Carbon|null $date
) { ) {
$this->portfolio = $portfolio; $this->portfolio = $portfolio;
$this->symbol = $symbol; $this->symbol = $symbol;
$this->transactionType = $transactionType; $this->transactionType = $transactionType;
$this->date = $date; $this->date = $date;
} }
/** /**
* Validate the attribute. * Validate the attribute.
*
* @param string $attribute
* @param mixed $value
* @param \Closure $fail
* @return void
*/ */
public function validate(string $attribute, mixed $value, \Closure $fail): void public function validate(string $attribute, mixed $value, \Closure $fail): void
{ {
if (is_null($this->portfolio) || is_null($this->symbol) || is_null($this->transactionType) || is_null($this->date)) {
//
$fail(__('The quantity must not be greater than the available quantity.'));
}
if ($this->transactionType == 'SELL') { if ($this->transactionType == 'SELL') {
$purchase_qty = $this->portfolio->transactions() $purchase_qty = $this->portfolio->transactions()
->symbol($this->symbol) ->symbol($this->symbol)
->buy() ->buy()
->beforeDate($this->date) ->beforeDate($this->date)
->sum('quantity'); ->sum('quantity');
$sales_qty = $this->portfolio->transactions() $sales_qty = $this->portfolio->transactions()
->symbol($this->symbol) ->symbol($this->symbol)
->sell() ->sell()
->beforeDate($this->date) ->beforeDate($this->date)
->sum('quantity'); ->sum('quantity');
$maxQuantity = $purchase_qty - $sales_qty; $maxQuantity = $purchase_qty - $sales_qty;
if ($value > $maxQuantity) { if (round($value, 3) > round($maxQuantity, 3)) {
$fail(__('The quantity must not be greater than the available quantity.')); $fail(__('The quantity must not be greater than the available quantity.'));
} }
} }
+7 -9
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Rules; namespace App\Rules;
use App\Interfaces\MarketData\MarketDataInterface; use App\Interfaces\MarketData\MarketDataInterface;
@@ -22,24 +24,20 @@ class SymbolValidationRule implements ValidationRule
/** /**
* Validate the attribute. * Validate the attribute.
*
* @param string $attribute
* @param mixed $value
* @param \Closure $fail
* @return void
*/ */
public function validate(string $attribute, mixed $value, \Closure $fail): void public function validate(string $attribute, mixed $value, \Closure $fail): void
{ {
$this->symbol = $value; $this->symbol = $value;
// Check if the symbol exists in the Market Data table first (avoid API call)
if (MarketData::find($this->symbol)) { if (MarketData::find($this->symbol)) {
return; return;
} }
// Check if the symbol exists in the Market Data table first (avoid API call) // Then check against market data provider
if (!app(MarketDataInterface::class)->exists($value)) { if (! app(MarketDataInterface::class)->exists($value)) {
$fail('The symbol provided (' . $this->symbol . ') is not valid'); $fail('The symbol provided ('.$this->symbol.') is not valid');
} }
} }
} }
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
// if (!function_exists('formatMoney')) { // if (!function_exists('formatMoney')) {
// /** // /**
// * Returns a formatted string for currency // * Returns a formatted string for currency
+12 -12
View File
@@ -1,19 +1,19 @@
<?php <?php
declare(strict_types=1);
namespace App\Support; namespace App\Support;
use App\Models\Holding;
use App\Models\Portfolio;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class Spotlight class Spotlight
{ {
public function search(Request $request) public function search(Request $request)
{ {
$results = collect(); $results = collect();
if (!$request->user()) { if (! $request->user()) {
return $results; return $results;
} }
@@ -22,13 +22,13 @@ class Spotlight
->where('title', 'LIKE', '%'.$request->input('search').'%') ->where('title', 'LIKE', '%'.$request->input('search').'%')
->limit(5) ->limit(5)
->get(); ->get();
$portfolios->each(function($portfolio) use ($results) { $portfolios->each(function ($portfolio) use ($results) {
$results->push([ $results->push([
'name' => 'Portfolio: '. $portfolio->title, 'name' => 'Portfolio: '.$portfolio->title,
'description' => null, 'description' => null,
'link' => route('portfolio.show', ['portfolio' => $portfolio->id]), 'link' => route('portfolio.show', ['portfolio' => $portfolio->id]),
'avatar' => null 'avatar' => null,
]); ]);
}); });
@@ -36,20 +36,20 @@ class Spotlight
->where('holdings.quantity', '>', 0) ->where('holdings.quantity', '>', 0)
->where(function ($query) use ($request) { ->where(function ($query) use ($request) {
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%') return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%'); ->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
}) })
->limit(5) ->limit(5)
->get(); ->get();
$holdings->each(function($holding) use ($results) { $holdings->each(function ($holding) use ($results) {
$results->push([ $results->push([
'name' => 'Holding: '.$holding->market_data->name.' ('.$holding->symbol.')', 'name' => 'Holding: '.$holding->market_data->name.' ('.$holding->symbol.')',
'description' => $holding->portfolio->title, 'description' => $holding->portfolio->title,
'link' => route('holding.show', ['portfolio' => $holding->portfolio->id, 'symbol' => $holding->symbol]), 'link' => route('holding.show', ['portfolio' => $holding->portfolio->id, 'symbol' => $holding->symbol]),
'avatar' => null 'avatar' => null,
]); ]);
}); });
return $results; return $results;
} }
} }
+11 -7
View File
@@ -1,6 +1,8 @@
<?php <?php
namespace App\Traits; declare(strict_types=1);
namespace App\Traits;
trait HasCompositePrimaryKey trait HasCompositePrimaryKey
{ {
@@ -17,17 +19,18 @@ trait HasCompositePrimaryKey
/** /**
* Set the keys for a save update query. * Set the keys for a save update query.
* *
* @param \Illuminate\Database\Eloquent\Builder $query * @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder * @return \Illuminate\Database\Eloquent\Builder
*/ */
protected function setKeysForSaveQuery($query) protected function setKeysForSaveQuery($query)
{ {
foreach ($this->getKeyName() as $key) { foreach ($this->getKeyName() as $key) {
// UPDATE: Added isset() per devflow's comment. // UPDATE: Added isset() per devflow's comment.
if (isset($this->$key)) if (isset($this->$key)) {
$query->where($key, '=', $this->$key); $query->where($key, '=', $this->$key);
else } else {
throw new \Exception(__METHOD__ . 'Missing part of the primary key: ' . $key); throw new \Exception(__METHOD__.'Missing part of the primary key: '.$key);
}
} }
return $query; return $query;
@@ -37,7 +40,7 @@ trait HasCompositePrimaryKey
/** /**
* Execute a query for a single record by ID. * Execute a query for a single record by ID.
* *
* @param array $ids Array of keys, like [column => value]. * @param array $ids Array of keys, like [column => value].
* @param array $columns * @param array $columns
* @return mixed|static * @return mixed|static
*/ */
@@ -48,6 +51,7 @@ trait HasCompositePrimaryKey
foreach ($me->getKeyName() as $key) { foreach ($me->getKeyName() as $key) {
$query->where($key, '=', $ids[$key]); $query->where($key, '=', $ids[$key]);
} }
return $query->first($columns); return $query->first($columns);
} }
} }
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use App\Models\ConnectedAccount;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* @property Collection $connectedAccounts
*/
trait HasConnectedAccounts
{
/**
* Determine if the user owns the given connected account.
*/
public function ownsConnectedAccount(mixed $connectedAccount): bool
{
return $this->id == optional($connectedAccount)->user_id;
}
/**
* Determine if the user has a specific account type.
*/
public function hasTokenFor(string $provider): bool
{
return $this->connectedAccounts->contains('provider', Str::lower($provider));
}
/**
* Attempt to retrieve the token for a given provider.
*/
public function getTokenFor(string $provider, mixed $default = null): mixed
{
if ($this->hasTokenFor($provider)) {
return $this->connectedAccounts
->where('provider', Str::lower($provider))
->first()
->token;
}
return $default;
}
/**
* Attempt to find a connected account that belongs to the user,
* for the given provider and ID.
*/
public function getConnectedAccountFor(string $provider, string $id): mixed
{
return $this->connectedAccounts
->where('provider', $provider)
->where('provider_id', $id)
->first();
}
/**
* Get all the connected accounts belonging to the user.
*/
public function connectedAccounts(): HasMany
{
return $this->hasMany(ConnectedAccount::class);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Support\Str;
trait WithTrimStrings
{
public function trimExceptions(): array
{
return [];
}
public function updatedWithTrimStrings(string $property, mixed $value): void
{
if (is_string($value) && ! in_array($property, $this->trimExceptions())) {
$this->fill([
$property => Str::trim($value),
]);
}
}
}
+11
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\View\Components; namespace App\View\Components;
use Illuminate\View\Component; use Illuminate\View\Component;
@@ -16,6 +18,7 @@ class AppLayout extends Component
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data> <x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
<div> <div>
<x-partials.nav-bar /> <x-partials.nav-bar />
<x-main with-nav full-width> <x-main with-nav full-width>
@@ -27,11 +30,19 @@ class AppLayout extends Component
</x-slot:sidebar> </x-slot:sidebar>
<x-slot:content> <x-slot:content>
{{ $slot }} {{ $slot }}
</x-slot:content> </x-slot:content>
</x-main> </x-main>
@if(session('toast'))
<script lang="text/javascript">
window.addEventListener('DOMContentLoaded', function () {
window.toast(JSON.parse(@json(session('toast'))))
});
</script>
@endif
<x-toast /> <x-toast />
</div> </div>
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\View\Components; namespace App\View\Components;
use Illuminate\View\Component; use Illuminate\View\Component;
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\View\Components; namespace App\View\Components;
use Illuminate\View\Component; use Illuminate\View\Component;
@@ -11,7 +13,7 @@ class MainLayout extends Component
// Slots // Slots
public mixed $body = null, public mixed $body = null,
) { } ) {}
/** /**
* Get the view / contents that represents the component. * Get the view / contents that represents the component.
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use App\Http\Middleware\SetLocale; use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
+16 -3
View File
@@ -5,18 +5,25 @@
"keywords": ["stocks", "dividends", "investments", "tracking"], "keywords": ["stocks", "dividends", "investments", "tracking"],
"license": "CC-BY-NC 4.0", "license": "CC-BY-NC 4.0",
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"ext-gd": "*",
"ext-mbstring": "*",
"ext-zip": "*",
"finnhub/client": "master@dev", "finnhub/client": "master@dev",
"laravel/framework": "^11.9", "hackeresq/filter-models": "dev-main",
"laravel/framework": "^11.35",
"laravel/jetstream": "^5.1", "laravel/jetstream": "^5.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.0",
"livewire/livewire": "^3.5", "livewire/livewire": "^3.5",
"livewire/volt": "^1.6", "livewire/volt": "^1.6",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
"openai-php/client": "^0.10.3",
"predis/predis": "^2.2", "predis/predis": "^2.2",
"robsontenorio/mary": "^1.35", "robsontenorio/mary": "^1.35",
"scheb/yahoo-finance-api": "^4.10", "scheb/yahoo-finance-api": "^4.11",
"staudenmeir/eloquent-has-many-deep": "^1.20", "staudenmeir/eloquent-has-many-deep": "^1.20",
"tschucki/alphavantage-laravel": "^0.0" "tschucki/alphavantage-laravel": "^0.0"
}, },
@@ -31,6 +38,12 @@
"repositories": [ "repositories": [
{ {
"type": "vcs", "type": "vcs",
"no-api": true,
"url": "https://github.com/hackeresq/filter-models"
},
{
"type": "vcs",
"no-api": true,
"url": "https://github.com/investbrainapp/finnhub-php" "url": "https://github.com/investbrainapp/finnhub-php"
} }
], ],
Generated
+1978 -686
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
return [ return [
'key' => env('ALPHAVANTAGE_API_KEY'), 'key' => env('ALPHAVANTAGE_API_KEY'),
]; ];
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
return [ return [
/* /*
@@ -13,7 +15,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Investbrain'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
return [ return [
/* /*

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