Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9a1bafa0 | |||
| 6f72a03ecf | |||
| 5b8e4c634e | |||
| 70c3f7162e | |||
| cb9199431a | |||
| cba9fe1e7b | |||
| baa49e77eb | |||
| b015462e50 | |||
| c9f1fc1bea | |||
| 1177886271 | |||
| 0e1c56dd18 | |||
| eefe237dff | |||
| 8d4e004177 | |||
| 1c63e2b856 | |||
| 3040cbf49a | |||
| 1a124a2571 | |||
| 26c8c3f3b9 | |||
| 50d814ebf6 | |||
| 7fc20876dd | |||
| 183108400e | |||
| 3055d34979 | |||
| 747f5f5f42 | |||
| 4db9409b94 | |||
| 8693bb29ca | |||
| 524d8ca41d | |||
| 3c77eca689 | |||
| 307f74b1d9 | |||
| 0c29393f3b | |||
| af3726cb91 | |||
| 0d40fd92f0 | |||
| 0f55d84355 | |||
| eafa889827 | |||
| 60cd880c2e | |||
| ea8de69863 | |||
| 11ef26e878 | |||
| 2770ebf958 | |||
| 536ca56c24 | |||
| d4407d3492 | |||
| 81766b4aba | |||
| e1cc040984 | |||
| cdda9d7ff7 | |||
| 9b6afe180d | |||
| ae22bb2e81 | |||
| f2e1211661 | |||
| 489bbbbec6 | |||
| d992a359a6 | |||
| c3e5d216ab | |||
| fee9cda5ba | |||
| 0c3a851e7d | |||
| 56ceb92c2b | |||
| fb96792821 | |||
| b512500c9c | |||
| b97a41f7ad | |||
| 2706ed7162 | |||
| 2494160c96 | |||
| dec253d860 | |||
| df9d863abb | |||
| 73dd885741 | |||
| 9cd6a37b05 | |||
| dfdb2af59f | |||
| 772e868a59 | |||
| 748589226e | |||
| 3b4f3b5efd | |||
| 791ba64dba | |||
| 8007e644d6 | |||
| 12cedd9e40 | |||
| 0d1e6543d1 | |||
| e0ab36ff61 | |||
| 6231baefe9 | |||
| 4c1da2308e | |||
| 4cde6b82ea | |||
| 4e6dcd6ff4 | |||
| 4f6e3c3711 | |||
| 863627bb42 | |||
| 07ebdaf77f | |||
| 642d31dc31 | |||
| 1235abadd0 | |||
| 25176c5a5f | |||
| 073ff88fa4 | |||
| 03dda7b947 | |||
| be859ad859 | |||
| d5f25c6f76 | |||
| 82a84cec97 | |||
| 41377757ec | |||
| 140f7d5a93 | |||
| 0e9bb1de0f | |||
| 9e6f879d16 | |||
| 28c326a34a | |||
| bc6251f22a | |||
| 6868c239a5 | |||
| b327059400 | |||
| 57495d36d8 | |||
| 89c1892013 | |||
| 7b4e16a9a1 | |||
| d6631fb9e1 | |||
| 06f3eaaaf7 | |||
| 1671abb5ee | |||
| d4af0436be | |||
| 9d9baa8857 | |||
| ff476ad406 | |||
| 0409677626 | |||
| 31af05bf70 | |||
| 0434bc6961 | |||
| 194ad4a532 | |||
| c19896beae | |||
| 66311a1c4c | |||
| 6aa7910af1 | |||
| cd47abddc6 | |||
| 9788070a16 | |||
| 46531ce4fa | |||
| 400ee1c6f2 | |||
| 8a3d3d1d34 | |||
| 3c41759cf3 | |||
| 336645d8e2 | |||
| 11ae07d69f | |||
| 81ed440404 | |||
| 3c368310ad | |||
| 7543c0a865 | |||
| ff725e0119 | |||
| ab24b528d1 | |||
| feab24ed2f | |||
| b441fb3953 | |||
| 812e672c11 | |||
| 2995f8b37e | |||
| 339de1ac9a | |||
| 22cab746e6 | |||
| f99efa9ddf | |||
| 10e00e6ef6 | |||
| d53d1a3ed3 | |||
| 5a04c33f13 | |||
| b6a123a90f | |||
| 5756fa06d7 | |||
| d1dbf3af62 | |||
| c1a4a44024 | |||
| 965303b6b0 | |||
| 6b424e2dcc | |||
| 3fd66d1138 | |||
| 740a29ce04 | |||
| 39160d654b | |||
| f93bfad3ce | |||
| 63c4c1c228 | |||
| a3d5ee0d1b | |||
| 8b067ece84 | |||
| 82dde818e6 | |||
| dd1e5c836c | |||
| 64cfdb32a9 | |||
| f793eb83c5 | |||
| f8d54d3813 | |||
| 318f8dd940 | |||
| bf8478a43f | |||
| e97e927ca3 | |||
| 6f847b9033 | |||
| 2802a018b9 | |||
| 5555e95e48 | |||
| 6ce9833e66 | |||
| 99c5ad3979 | |||
| bcb1820095 | |||
| 6e75713589 | |||
| 074cfa70fb | |||
| 3cb0ad5c86 | |||
| da9e7dd5c7 | |||
| 104096471d | |||
| 83c5561edb | |||
| 9d1e17cfc0 | |||
| 6e14852f55 | |||
| 34a8de221f | |||
| 51c33ebec0 | |||
| e4d45f391c | |||
| 23615c7309 | |||
| 1c774096ab | |||
| d70eeb6a0b | |||
| 31b551e34a | |||
| 914d65574b | |||
| 231c9ffc6e | |||
| 9e173ecc35 | |||
| 367fd7802b | |||
| 267049b87f | |||
| ed4d955507 | |||
| 32fed82772 | |||
| 90dafccec6 | |||
| 7d119a8c24 | |||
| d0fbf44fa0 | |||
| dc1042e736 | |||
| 5fbe1ef9d4 | |||
| 9d808cd447 | |||
| 5415c62d49 | |||
| e69a8fa4e6 | |||
| 80b25115a3 | |||
| 5bcbec82f4 | |||
| ab491971e4 | |||
| 2b9c3fb469 | |||
| 83abdd4169 | |||
| 7bdf22d188 | |||
| 99830d1a55 | |||
| 85660d7b9d | |||
| 5fa3d6a83c | |||
| 17e9bce1ae | |||
| 854fa1c7e9 | |||
| 02a3a1ea9f | |||
| 1db90cce48 | |||
| 0748ff012d | |||
| 5bb601f869 | |||
| 835c2115f2 |
+24
-21
@@ -1,26 +1,38 @@
|
|||||||
APP_NAME=Investbrain
|
APP_NAME=Investbrain
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=true
|
||||||
APP_TIMEZONE=UTC
|
APP_TIMEZONE=UTC
|
||||||
APP_URL=http://localhost
|
|
||||||
ASSET_URL="${APP_URL}"
|
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
|
APP_URL="http://localhost:${APP_PORT}"
|
||||||
SELF_HOSTED=true
|
SELF_HOSTED=true
|
||||||
|
REGISTRATION_ENABLED=true
|
||||||
|
|
||||||
|
# ASSET_URL="http://localhost:8000" # (optional) webroot for static assets (css, js, images, etc)
|
||||||
|
|
||||||
|
AI_CHAT_ENABLED=false
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_ORGANIZATION=
|
||||||
|
|
||||||
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
|
MARKET_DATA_REFRESH=30
|
||||||
|
ALPHAVANTAGE_API_KEY=
|
||||||
|
FINNHUB_API_KEY=
|
||||||
|
|
||||||
|
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_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
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
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
@@ -34,14 +46,10 @@ 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=127.0.0.1
|
||||||
@@ -58,11 +66,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,17 +2,29 @@
|
|||||||
|
|
||||||
## 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 an integration with OpenAI 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 downloads all the necessary dependencies and seamlessly builds 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 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.
|
||||||
|
|
||||||
@@ -40,19 +52,29 @@ 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 .env file:
|
||||||
|
|
||||||
```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 .env file. Each should be separated by a comma:
|
||||||
|
|
||||||
@@ -64,7 +86,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,24 +107,45 @@ MARKET_DATA_PROVIDER=yahoo,alphavantage,custom_provider
|
|||||||
|
|
||||||
Feel free to submit a PR with any custom providers you create.
|
Feel free to submit a PR with any custom providers you create.
|
||||||
|
|
||||||
|
## Import / Export
|
||||||
|
|
||||||
|
Investbrain includes a convenient feature which allows you to import and export 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 (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. Changes can be made in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file before installation.
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
| ------------- | ------------- | ------------- |
|
| ------------- | ------------- | ------------- |
|
||||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
| 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, so if you decide to make any changes to these default configurations, you'll have to restart the Docker containers before your changes take effect.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
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
|
||||||
@@ -114,7 +157,7 @@ Then pull the latest updates from this repository using git:
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
Then bring the containers back up!
|
Finally bring the containers back up!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up
|
docker compose up
|
||||||
@@ -143,9 +186,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 +237,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
@@ -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.
|
||||||
@@ -3,14 +3,21 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Traits\WithTrimStrings;
|
||||||
|
use Laravel\Jetstream\Jetstream;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
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.
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Traits\WithTrimStrings;
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,7 +13,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,9 +41,17 @@ class RefreshDividendData extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']);
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
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);
|
$this->line('Refreshing ' . $holding->symbol);
|
||||||
|
|
||||||
Dividend::refreshDividendData($holding->symbol);
|
Dividend::refreshDividendData($holding->symbol);
|
||||||
|
|||||||
@@ -14,7 +14,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 +41,21 @@ 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();
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
$holdings->myHoldings($this->option('user'));
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
foreach ($holdings->get() as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing ' . $holding->symbol);
|
||||||
|
|
||||||
MarketData::getMarketData($holding->symbol);
|
MarketData::getMarketData($holding->symbol, $force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,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,9 +40,13 @@ class RefreshSplitData extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$holdings = Holding::where('quantity', '>', 0)->distinct()->get(['symbol']);
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
foreach ($holdings as $holding) {
|
if (!($this->option('force') ?? false)) {
|
||||||
|
$holdings->where('quantity', '>', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holdings->get(['symbol']) as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing ' . $holding->symbol);
|
||||||
|
|
||||||
Split::refreshSplitData($holding->symbol);
|
Split::refreshSplitData($holding->symbol);
|
||||||
|
|||||||
@@ -12,7 +12,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,9 +40,13 @@ 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')) {
|
||||||
|
$holdings->myHoldings($this->option('user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($holdings->get() as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing ' . $holding->symbol);
|
||||||
|
|
||||||
$holding->syncTransactionsAndDividends();
|
$holding->syncTransactionsAndDividends();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Cost Basis',
|
'Cost Basis',
|
||||||
'Sale Price',
|
'Sale Price',
|
||||||
'Split',
|
'Split',
|
||||||
|
'Reinvested Dividend',
|
||||||
'Date',
|
'Date',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated'
|
'Updated'
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use Illuminate\Support\MessageBag;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use App\Notifications\VerifyConnectedAccountNotification;
|
||||||
|
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,14 @@ class DashboardController extends Controller
|
|||||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->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()
|
||||||
|
->withoutWishlists()
|
||||||
->withPortfolioMetrics()
|
->withPortfolioMetrics()
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,8 @@ class HoldingController extends Controller
|
|||||||
->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,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ 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;
|
||||||
|
|
||||||
class PortfolioController extends Controller
|
class PortfolioController extends Controller
|
||||||
{
|
{
|
||||||
@@ -20,12 +20,16 @@ class PortfolioController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Portfolio $portfolio)
|
public function show(Request $request, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
|
if ($request->user()->cannot('readOnly', $portfolio)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$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) {
|
||||||
@@ -35,7 +39,9 @@ class PortfolioController extends Controller
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$formattedHoldings = $portfolio->getFormattedHoldings();
|
||||||
|
|
||||||
return view('portfolio.show', compact(['portfolio', 'metrics']));
|
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Imports;
|
namespace App\Imports;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use App\Imports\Sheets\PortfoliosSheet;
|
use App\Imports\Sheets\PortfoliosSheet;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use App\Console\Commands\SyncDailyChange;
|
||||||
|
use App\Console\Commands\SyncHoldingData;
|
||||||
use App\Imports\Sheets\DailyChangesSheet;
|
use App\Imports\Sheets\DailyChangesSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
|
use Maatwebsite\Excel\Events\AfterImport;
|
||||||
use Maatwebsite\Excel\Concerns\Importable;
|
use Maatwebsite\Excel\Concerns\Importable;
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeImport;
|
||||||
|
use Maatwebsite\Excel\Events\ImportFailed;
|
||||||
|
use App\Console\Commands\RefreshMarketData;
|
||||||
|
use App\Console\Commands\RefreshDividendData;
|
||||||
|
use App\Models\BackupImport as BackupImportModel;
|
||||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
|
||||||
class BackupImport implements WithMultipleSheets, WithEvents
|
class BackupImport implements WithMultipleSheets, WithEvents
|
||||||
@@ -14,24 +24,53 @@ class BackupImport implements WithMultipleSheets, WithEvents
|
|||||||
|
|
||||||
use Importable;
|
use Importable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public BackupImportModel $backupImportModel
|
||||||
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,58 +2,97 @@
|
|||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use Exception;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
use App\Models\DailyChange;
|
use App\Models\DailyChange;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
use App\Imports\ValidatesPortfolioPermissions;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
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;
|
|
||||||
|
|
||||||
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading
|
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
||||||
{
|
{
|
||||||
use ValidatesPortfolioPermissions;
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,52 @@
|
|||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\BackupImport;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
|
||||||
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows
|
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows, WithEvents
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
|
||||||
'id' => $portfolio['portfolio_id']
|
'id' => $portfolio['portfolio_id']
|
||||||
], [
|
], [
|
||||||
'id' => $portfolio['portfolio_id'] ?? null,
|
'id' => $portfolio['portfolio_id'] ?? null,
|
||||||
'title' => $portfolio['title'],
|
'title' => $portfolio['title'],
|
||||||
'wishlist' => $portfolio['wishlist'] ?? false,
|
'wishlist' => $portfolio['wishlist'] ?? false,
|
||||||
'notes' => $portfolio['notes'],
|
'notes' => $portfolio['notes'],
|
||||||
|
'owner_id' => $this->backupImport->user_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +56,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'],
|
||||||
|
|||||||
@@ -2,69 +2,122 @@
|
|||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
use App\Imports\ValidatesPortfolioPermissions;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
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;
|
|
||||||
|
|
||||||
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithChunkReading
|
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
||||||
{
|
{
|
||||||
use ValidatesPortfolioPermissions;
|
|
||||||
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function(BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing transactions...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function collection(Collection $transactions)
|
public function collection(Collection $transactions)
|
||||||
{
|
{
|
||||||
$this->validatePortfolioPermissions($transactions);
|
|
||||||
|
|
||||||
Transaction::withoutEvents(function () use ($transactions) {
|
|
||||||
|
|
||||||
foreach ($transactions->sortBy('date') as $transaction) {
|
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
||||||
|
|
||||||
Transaction::where('id', $transaction['transaction_id'])
|
$this->validatePortfolioAccess($chunk);
|
||||||
->firstOr(function () use ($transaction) {
|
|
||||||
|
|
||||||
$transaction = Transaction::make()->forceFill([
|
// have to cast to native values
|
||||||
'id' => $transaction['transaction_id'],
|
$chunk = $chunk->map(function ($transaction) {
|
||||||
'symbol' => $transaction['symbol'],
|
|
||||||
'portfolio_id' => $transaction['portfolio_id'],
|
|
||||||
'transaction_type' => $transaction['transaction_type'],
|
|
||||||
'quantity' => $transaction['quantity'],
|
|
||||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
|
||||||
'sale_price' => $transaction['sale_price'],
|
|
||||||
'split' => $transaction['split'] ?? null,
|
|
||||||
'date' => $transaction['date'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$transaction->save();
|
return [
|
||||||
|
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||||
|
'symbol' => strtoupper($transaction['symbol']),
|
||||||
|
'portfolio_id' => $transaction['portfolio_id'],
|
||||||
|
'transaction_type' => $transaction['transaction_type'],
|
||||||
|
'quantity' => $transaction['quantity'],
|
||||||
|
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||||
|
'sale_price' => $transaction['sale_price'],
|
||||||
|
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||||
|
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||||
|
'date' => Carbon::parse($transaction['date'])->format('Y-m-d')
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return $transaction;
|
Transaction::upsert(
|
||||||
})
|
$chunk->toArray(),
|
||||||
->syncToHolding();
|
['id'],
|
||||||
}
|
[
|
||||||
|
'id',
|
||||||
|
'symbol',
|
||||||
|
'portfolio_id',
|
||||||
|
'transaction_type',
|
||||||
|
'quantity',
|
||||||
|
'cost_basis',
|
||||||
|
'sale_price',
|
||||||
|
'split',
|
||||||
|
'reinvested_dividend',
|
||||||
|
'date'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// stub out related holdings
|
||||||
|
$chunk->unique(fn($item) => $item['symbol'] . $item['portfolio_id'])
|
||||||
|
->each(function($holding) {
|
||||||
|
|
||||||
|
Holding::firstOrCreate([
|
||||||
|
'symbol' => $holding['symbol'],
|
||||||
|
'portfolio_id' => $holding['portfolio_id']
|
||||||
|
], [
|
||||||
|
'quantity' => 0,
|
||||||
|
'average_cost_basis' => 0,
|
||||||
|
'splits_synced_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function batchSize(): int
|
||||||
|
{
|
||||||
|
return 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'transaction_id' => ['sometimes', 'nullable'],
|
'transaction_id' => ['sometimes', 'nullable', 'uuid'],
|
||||||
'symbol' => ['required', 'string'],
|
'symbol' => ['required', 'string'],
|
||||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
|
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||||
|
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'sale_price' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chunkSize(): int
|
|
||||||
{
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,10 @@ namespace App\Interfaces\MarketData;
|
|||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
use Tschucki\Alphavantage\Facades\Alphavantage;
|
use Tschucki\Alphavantage\Facades\Alphavantage;
|
||||||
|
|
||||||
class AlphaVantageMarketData implements MarketDataInterface
|
class AlphaVantageMarketData implements MarketDataInterface
|
||||||
@@ -15,22 +19,22 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
return $this->quote($symbol)->isNotEmpty();
|
return $this->quote($symbol)->isNotEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
if (empty($quote)) return new Quote();
|
||||||
'symbol-'.$symbol,
|
|
||||||
|
$fundamental = cache()->remember(
|
||||||
|
'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' => Arr::get($fundamental, 'Symbol'),
|
||||||
'market_value' => Arr::get($quote, '05. price'),
|
'market_value' => Arr::get($quote, '05. price'),
|
||||||
@@ -61,12 +65,11 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
})
|
})
|
||||||
->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'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +85,11 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
})
|
})
|
||||||
->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'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +109,11 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$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')
|
||||||
]];
|
]) ];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,10 @@ namespace App\Interfaces\MarketData;
|
|||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
|
||||||
class FakeMarketData implements MarketDataInterface
|
class FakeMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -13,10 +17,10 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
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,7 +31,7 @@ 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
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,21 +39,21 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
|
|
||||||
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,
|
||||||
],
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +61,11 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
|
|
||||||
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,
|
||||||
],
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +77,11 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$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);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ namespace App\Interfaces\MarketData;
|
|||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
|
||||||
class FinnhubMarketData implements MarketDataInterface
|
class FinnhubMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -24,23 +28,21 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
return $this->quote($symbol)->isNotEmpty();
|
return $this->quote($symbol)->isNotEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote($symbol): Collection
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
$quote = $this->client->quote($symbol);
|
$quote = $this->client->quote($symbol);
|
||||||
|
|
||||||
|
if (empty($quote)) return new Quote();
|
||||||
|
|
||||||
$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'),
|
||||||
@@ -61,12 +63,11 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
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'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +78,11 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
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'),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +96,11 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
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],
|
||||||
]];
|
]) ];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
|
||||||
interface MarketDataInterface
|
interface MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -20,9 +21,9 @@ interface MarketDataInterface
|
|||||||
*
|
*
|
||||||
* @param String $symbol
|
* @param String $symbol
|
||||||
*
|
*
|
||||||
* @return Collection
|
* @return Quote
|
||||||
*/
|
*/
|
||||||
public function quote(String $symbol): Collection;
|
public function quote(String $symbol): Quote;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dividend data
|
* Get dividend data
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||||
|
|
||||||
|
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,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class MarketDataType extends Collection
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function __construct($items = [])
|
||||||
|
{
|
||||||
|
|
||||||
|
foreach($this->getArrayableItems($items) as $key => $value) {
|
||||||
|
|
||||||
|
$this->{$key} = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray()
|
||||||
|
{
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __set($key, $value)
|
||||||
|
{
|
||||||
|
$this->{'set'.Str::studly($key)}($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __get($key)
|
||||||
|
{
|
||||||
|
return $this->items[$key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||||
|
|
||||||
|
class Ohlc extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setSymbol(string $symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = $symbol;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpen($open): self
|
||||||
|
{
|
||||||
|
$this->items['open'] = (float) $open;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpen(): float
|
||||||
|
{
|
||||||
|
return $this->items['open'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHigh($high): self
|
||||||
|
{
|
||||||
|
$this->items['high'] = (float) $high;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHigh(): float
|
||||||
|
{
|
||||||
|
return $this->items['high'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLow($low): self
|
||||||
|
{
|
||||||
|
$this->items['low'] = (float) $low;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLow(): float
|
||||||
|
{
|
||||||
|
return $this->items['low'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClose($close): self
|
||||||
|
{
|
||||||
|
$this->items['close'] = (float) $close;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClose(): float
|
||||||
|
{
|
||||||
|
return $this->items['close'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDate(String|DateTime $date): self
|
||||||
|
{
|
||||||
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['date'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||||
|
|
||||||
|
class Quote extends MarketDataType
|
||||||
|
{
|
||||||
|
public function setName($name): self
|
||||||
|
{
|
||||||
|
$this->items['name'] = (string) $name;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->items['name'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSymbol($symbol): self
|
||||||
|
{
|
||||||
|
$this->items['symbol'] = (string) $symbol;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSymbol(): string
|
||||||
|
{
|
||||||
|
return $this->items['symbol'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMarketValue($marketValue): self
|
||||||
|
{
|
||||||
|
$this->items['market_value'] = (float) $marketValue;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMarketValue(): float
|
||||||
|
{
|
||||||
|
return $this->items['market_value'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFiftyTwoWeekHigh($high): self
|
||||||
|
{
|
||||||
|
$this->items['fifty_two_week_high'] = (float) $high;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFiftyTwoWeekHigh(): float
|
||||||
|
{
|
||||||
|
return $this->items['fifty_two_week_high'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFiftyTwoWeekLow($low): self
|
||||||
|
{
|
||||||
|
$this->items['fifty_two_week_low'] = (float) $low;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFiftyTwoWeekLow(): float
|
||||||
|
{
|
||||||
|
return $this->items['fifty_two_week_low'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setForwardPE($pe): self
|
||||||
|
{
|
||||||
|
$this->items['forward_pe'] = (float) $pe;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForwardPE(): float
|
||||||
|
{
|
||||||
|
return $this->items['forward_pe'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrailingPE($pe): self
|
||||||
|
{
|
||||||
|
$this->items['trailing_pe'] = (float) $pe;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrailingPE(): float
|
||||||
|
{
|
||||||
|
return $this->items['trailing_pe'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMarketCap($cap): self
|
||||||
|
{
|
||||||
|
$this->items['market_cap'] = (int) $cap;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMarketCap(): int
|
||||||
|
{
|
||||||
|
return $this->items['market_cap'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBookValue($value): self
|
||||||
|
{
|
||||||
|
$this->items['book_value'] = (float) $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBookValue(): float
|
||||||
|
{
|
||||||
|
return $this->items['book_value'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastDividendDate(mixed $date): self
|
||||||
|
{
|
||||||
|
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastDividendDate(): ?DateTime
|
||||||
|
{
|
||||||
|
return $this->items['last_dividend_date'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDividendYield($yield): self
|
||||||
|
{
|
||||||
|
$this->items['dividend_yield'] = (float) $yield;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDividendYield(): float
|
||||||
|
{
|
||||||
|
return $this->items['dividend_yield'] ?? 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use App\Interfaces\MarketData\Types\MarketDataType;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ namespace App\Interfaces\MarketData;
|
|||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Scheb\YahooFinanceApi\ApiClient;
|
use Scheb\YahooFinanceApi\ApiClient;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||||
|
|
||||||
class YahooMarketData implements MarketDataInterface
|
class YahooMarketData implements MarketDataInterface
|
||||||
@@ -22,14 +26,14 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
return $this->quote($symbol)->isNotEmpty();
|
return $this->quote($symbol)->isNotEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
if (empty($quote)) return collect();
|
||||||
|
|
||||||
return collect([
|
return new Quote([
|
||||||
'name' => $quote->getLongName() ?? $quote->getShortName(),
|
'name' => $quote->getLongName() ?? $quote->getShortName(),
|
||||||
'symbol' => $quote->getSymbol(),
|
'symbol' => $quote->getSymbol(),
|
||||||
'market_value' => $quote->getRegularMarketPrice(),
|
'market_value' => $quote->getRegularMarketPrice(),
|
||||||
@@ -50,11 +54,11 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
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(),
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +69,11 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
->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],
|
||||||
];
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +85,11 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$date = $history->getDate()->format('Y-m-d');
|
$date = $history->getDate()->format('Y-m-d');
|
||||||
|
|
||||||
return [ $date => [
|
return [ $date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => (float) $history->getClose(),
|
'close' => $history->getClose(),
|
||||||
]];
|
]) ];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use App\Notifications\ImportSucceededNotification;
|
||||||
|
use App\Notifications\ImportFailedNotification;
|
||||||
|
use App\Imports\BackupImport as BackupImportExcel;
|
||||||
|
|
||||||
|
class BackupImportJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public $tries = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds the job can run before timing out.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $timeout = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate if the job should be marked as failed on timeout.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $failOnTimeout = true;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {
|
||||||
|
$this->user = User::find($this->backupImport->user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
|
||||||
|
|
||||||
|
$this->user->notify(new ImportSucceededNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(?Throwable $e): void
|
||||||
|
{
|
||||||
|
$this->backupImport->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Error: '. substr($e->getMessage(), 0, 220),
|
||||||
|
'has_errors' => true,
|
||||||
|
'completed_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->user->notify(new ImportFailedNotification($e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
||||||
|
class AiChat extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'role',
|
||||||
|
'content'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($chat) {
|
||||||
|
|
||||||
|
$chat->user_id = auth()->user()->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user() {
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use App\Imports\BackupImport as BackupImportExcel;
|
||||||
|
use App\Jobs\BackupImportJob;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
|
||||||
|
class BackupImport extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $table = 'backup_import_jobs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'path',
|
||||||
|
'status', // pending, in_progress, success, failed
|
||||||
|
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
|
||||||
|
'has_errors',
|
||||||
|
'completed_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($import) {
|
||||||
|
|
||||||
|
$import->status = 'pending';
|
||||||
|
$import->message = __('Import starting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
static::created(function ($import) {
|
||||||
|
|
||||||
|
BackupImportJob::dispatch($import);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected $appends = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'has_errors' => 'boolean',
|
||||||
|
'completed_at' => 'datetime'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,10 +42,16 @@ class DailyChange extends Model
|
|||||||
{
|
{
|
||||||
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()
|
||||||
{
|
{
|
||||||
|
|||||||
+46
-15
@@ -6,6 +6,7 @@ use App\Models\Holding;
|
|||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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;
|
||||||
@@ -26,7 +27,7 @@ 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() {
|
||||||
@@ -49,25 +50,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_dividend_update->addHours(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip refresh if there's already recent data
|
||||||
|
if ($start_date->greaterThan($end_date)) {
|
||||||
|
|
||||||
$start_date = $dividends_meta->last_date->addHours(48);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get some data
|
// get some data
|
||||||
@@ -86,21 +91,22 @@ 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('
|
||||||
@@ -115,7 +121,7 @@ class Dividend extends Model
|
|||||||
')
|
')
|
||||||
->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();
|
||||||
@@ -130,4 +136,29 @@ class Dividend extends Model
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-37
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Split;
|
use App\Models\Split;
|
||||||
|
use App\Models\AiChat;
|
||||||
use App\Models\Dividend;
|
use App\Models\Dividend;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
@@ -26,11 +27,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 = [
|
protected $attributes = [
|
||||||
@@ -71,7 +74,7 @@ class Holding extends Model
|
|||||||
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")
|
||||||
@@ -79,10 +82,23 @@ class Holding extends Model
|
|||||||
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")
|
||||||
|
->selectRaw("SUM(
|
||||||
|
(CASE WHEN transaction_type = 'BUY'
|
||||||
|
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
|
||||||
|
- CASE WHEN transaction_type = 'SELL'
|
||||||
|
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')
|
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||||
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
||||||
->orderBy('dividends.date', 'DESC')
|
->orderBy('dividends.date', 'DESC')
|
||||||
@@ -91,7 +107,8 @@ class Holding extends Model
|
|||||||
->from('transactions')
|
->from('transactions')
|
||||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||||
});
|
})
|
||||||
|
->having('total_received', '>', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +132,16 @@ class Holding extends Model
|
|||||||
->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')
|
||||||
@@ -129,7 +156,7 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
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)
|
||||||
@@ -143,14 +170,15 @@ class Holding extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists($query) {
|
public function scopeWithoutWishlists($query) {
|
||||||
return $query->join('portfolios', 'portfolios.id', 'holdings.portfolio_id')
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
->where('portfolios.wishlist', 0);
|
$query->where('portfolios.wishlist', 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyHoldings($query)
|
public function scopeMyHoldings($query, $userId = null)
|
||||||
{
|
{
|
||||||
return $query->whereHas('portfolio', function($query) {
|
return $query->whereHas('portfolio', function($query) use ($userId) {
|
||||||
$query->whereRelation('users', 'id', auth()->user()->id);
|
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,50 +201,46 @@ class Holding extends Model
|
|||||||
'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 qtyOwned(\Illuminate\Support\Carbon $date = null)
|
||||||
|
{
|
||||||
|
if ($date == null) $date = now();
|
||||||
|
|
||||||
|
$transactions = $this->transactions->where('date', '<=', $date);
|
||||||
|
|
||||||
|
$purchases = $transactions->where('transaction_type', 'BUY')->sum('quantity');
|
||||||
|
|
||||||
|
$sales = $transactions->where('transaction_type', 'SELL')->sum('quantity');
|
||||||
|
|
||||||
|
return $purchases - $sales;
|
||||||
|
}
|
||||||
|
|
||||||
public function dailyPerformance(
|
public function dailyPerformance(
|
||||||
\Illuminate\Support\Carbon $start_date = null,
|
\Illuminate\Support\Carbon $start_date = null,
|
||||||
\Illuminate\Support\Carbon $end_date = null,
|
\Illuminate\Support\Carbon $end_date = null,
|
||||||
@@ -229,6 +253,9 @@ class Holding extends Model
|
|||||||
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("(
|
||||||
@@ -246,14 +273,16 @@ class Holding extends Model
|
|||||||
->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
|
||||||
@@ -273,4 +302,17 @@ class Holding extends Model
|
|||||||
->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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ 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
|
||||||
@@ -58,7 +58,8 @@ class MarketData extends Model
|
|||||||
|
|
||||||
// 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')
|
||||||
) {
|
) {
|
||||||
|
|||||||
+130
-39
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\AiChat;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
@@ -20,13 +23,15 @@ 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) {
|
static::saved(function ($portfolio) {
|
||||||
|
|
||||||
self::syncUsers($model);
|
self::ensurePortfolioHasOwner($portfolio);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
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()
|
||||||
@@ -60,6 +65,16 @@ class Portfolio extends Model
|
|||||||
return $this->hasMany(DailyChange::class);
|
return $this->hasMany(DailyChange::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Related chats for portfolio
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function chats()
|
||||||
|
{
|
||||||
|
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeMyPortfolios()
|
public function scopeMyPortfolios()
|
||||||
{
|
{
|
||||||
return $this->whereHas('users', function ($query) {
|
return $this->whereHas('users', function ($query) {
|
||||||
@@ -67,28 +82,54 @@ class Portfolio extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
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];
|
}
|
||||||
// };
|
|
||||||
|
|
||||||
// save
|
|
||||||
$model->users()->sync($user_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncDailyChanges(): void
|
public function syncDailyChanges(): void
|
||||||
@@ -108,43 +149,52 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
$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 = Arr::get($history, 'close', 0);
|
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||||
|
|
||||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
$total_market_value = $daily_performance->get($date)->owned * $close;
|
||||||
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
$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 ($daily as $date => $performance) {
|
foreach ($holding_performance as $date => $performance) {
|
||||||
if (!isset($total_performance[$date])) {
|
if (Arr::get($total_performance, $date) == null) {
|
||||||
|
|
||||||
$total_performance[$date] = $performance;
|
$total_performance[$date] = $performance;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
||||||
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
||||||
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
||||||
@@ -156,10 +206,51 @@ 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();
|
|
||||||
|
$this->daily_change()->upsert(
|
||||||
DailyChange::insert($total_performance);
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Transaction extends Model
|
|||||||
'cost_basis',
|
'cost_basis',
|
||||||
'sale_price',
|
'sale_price',
|
||||||
'split',
|
'split',
|
||||||
|
'reinvested_dividend'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
@@ -30,6 +31,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 +52,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ class Transaction extends Model
|
|||||||
|
|
||||||
public function scopeBeforeDate($query, $date)
|
public function scopeBeforeDate($query, $date)
|
||||||
{
|
{
|
||||||
return $query->whereDate('date', '<', $date);
|
return $query->whereDate('date', '<=', $date);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyTransactions()
|
public function scopeMyTransactions()
|
||||||
|
|||||||
+6
-4
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Traits\HasConnectedAccounts;
|
||||||
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
use Laravel\Jetstream\HasProfilePhoto;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -13,8 +12,9 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasApiTokens;
|
use HasApiTokens;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
@@ -23,6 +23,7 @@ class User extends Authenticatable
|
|||||||
use TwoFactorAuthenticatable;
|
use TwoFactorAuthenticatable;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
use HasRelationships;
|
use HasRelationships;
|
||||||
|
use HasConnectedAccounts;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@@ -31,6 +32,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 +53,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()
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
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,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
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,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $connected_account_id
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$connected_account = ConnectedAccount::find($this->connected_account_id);
|
||||||
|
$provider = config("services.$connected_account->provider.name");
|
||||||
|
|
||||||
|
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->greeting('Welcome back!')
|
||||||
|
->subject("Connect your $provider account with Investbrain")
|
||||||
|
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
||||||
|
->action("Connect $provider", $url)
|
||||||
|
->line("If you do not recognize this activity, we recommend [changing your password](".route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
|
||||||
|
class PortfolioPolicy
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function readOnly(User $user, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
return !!$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Jetstream\Features;
|
||||||
use App\Actions\Jetstream\DeleteUser;
|
use App\Actions\Jetstream\DeleteUser;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
|
|
||||||
@@ -26,6 +29,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
Jetstream::deleteUsersUsing(DeleteUser::class);
|
||||||
|
|
||||||
|
if ( config('investbrain.self_hosted', false) ) {
|
||||||
|
|
||||||
|
Config::set(
|
||||||
|
'jetstream.features',
|
||||||
|
array_keys(Arr::except(array_values(config('jetstream.features')), Features::termsAndPrivacyPolicy()))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
|
|
||||||
$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.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use App\Models\ConnectedAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Collection $connectedAccounts
|
||||||
|
*/
|
||||||
|
trait HasConnectedAccounts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user owns the given connected account.
|
||||||
|
*/
|
||||||
|
public function ownsConnectedAccount(mixed $connectedAccount): bool
|
||||||
|
{
|
||||||
|
return $this->id == optional($connectedAccount)->user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user has a specific account type.
|
||||||
|
*/
|
||||||
|
public function hasTokenFor(string $provider): bool
|
||||||
|
{
|
||||||
|
return $this->connectedAccounts->contains('provider', Str::lower($provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve the token for a given provider.
|
||||||
|
*/
|
||||||
|
public function getTokenFor(string $provider, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if ($this->hasTokenFor($provider)) {
|
||||||
|
return $this->connectedAccounts
|
||||||
|
->where('provider', Str::lower($provider))
|
||||||
|
->first()
|
||||||
|
->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to find a connected account that belongs to the user,
|
||||||
|
* for the given provider and ID.
|
||||||
|
*/
|
||||||
|
public function getConnectedAccountFor(string $provider, string $id): mixed
|
||||||
|
{
|
||||||
|
return $this->connectedAccounts
|
||||||
|
->where('provider', $provider)
|
||||||
|
->where('provider_id', $id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the connected accounts belonging to the user.
|
||||||
|
*/
|
||||||
|
public function connectedAccounts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ConnectedAccount::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,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 +28,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>
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -10,13 +10,16 @@
|
|||||||
"laravel/framework": "^11.9",
|
"laravel/framework": "^11.9",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1490
-557
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Excel;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Reader\Csv;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'exports' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Chunk size
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using FromQuery, the query is automatically chunked.
|
||||||
|
| Here you can specify how big the chunk should be.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'chunk_size' => 1000,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pre-calculate formulas during export
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'pre_calculate_formulas' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Enable strict null comparison
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When enabling strict null comparison empty cells ('') will
|
||||||
|
| be added to the sheet.
|
||||||
|
*/
|
||||||
|
'strict_null_comparison' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| CSV Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure e.g. delimiter, enclosure and line ending for CSV exports.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'csv' => [
|
||||||
|
'delimiter' => ',',
|
||||||
|
'enclosure' => '"',
|
||||||
|
'line_ending' => PHP_EOL,
|
||||||
|
'use_bom' => false,
|
||||||
|
'include_separator_line' => false,
|
||||||
|
'excel_compatibility' => false,
|
||||||
|
'output_encoding' => '',
|
||||||
|
'test_auto_detect' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Worksheet properties
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure e.g. default title, creator, subject,...
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'properties' => [
|
||||||
|
'creator' => '',
|
||||||
|
'lastModifiedBy' => '',
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'subject' => '',
|
||||||
|
'keywords' => '',
|
||||||
|
'category' => '',
|
||||||
|
'manager' => '',
|
||||||
|
'company' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'imports' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Read Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When dealing with imports, you might only be interested in the
|
||||||
|
| data that the sheet exists. By default we ignore all styles,
|
||||||
|
| however if you want to do some logic based on style data
|
||||||
|
| you can enable it by setting read_only to false.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'read_only' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Ignore Empty
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When dealing with imports, you might be interested in ignoring
|
||||||
|
| rows that have null values or empty strings. By default rows
|
||||||
|
| containing empty strings or empty values are not ignored but can be
|
||||||
|
| ignored by enabling the setting ignore_empty to true.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'ignore_empty' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Heading Row Formatter
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure the heading row formatter.
|
||||||
|
| Available options: none|slug|custom
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'heading_row' => [
|
||||||
|
'formatter' => 'slug',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| CSV Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure e.g. delimiter, enclosure and line ending for CSV imports.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'csv' => [
|
||||||
|
'delimiter' => null,
|
||||||
|
'enclosure' => '"',
|
||||||
|
'escape_character' => '\\',
|
||||||
|
'contiguous' => false,
|
||||||
|
'input_encoding' => Csv::GUESS_ENCODING,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Worksheet properties
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure e.g. default title, creator, subject,...
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'properties' => [
|
||||||
|
'creator' => '',
|
||||||
|
'lastModifiedBy' => '',
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'subject' => '',
|
||||||
|
'keywords' => '',
|
||||||
|
'category' => '',
|
||||||
|
'manager' => '',
|
||||||
|
'company' => '',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cell Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure middleware that is executed on getting a cell value
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'cells' => [
|
||||||
|
'middleware' => [
|
||||||
|
//\Maatwebsite\Excel\Middleware\TrimCellValue::class,
|
||||||
|
//\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Extension detector
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure here which writer/reader type should be used when the package
|
||||||
|
| needs to guess the correct type based on the extension alone.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'extension_detector' => [
|
||||||
|
'xlsx' => Excel::XLSX,
|
||||||
|
'xlsm' => Excel::XLSX,
|
||||||
|
'xltx' => Excel::XLSX,
|
||||||
|
'xltm' => Excel::XLSX,
|
||||||
|
'xls' => Excel::XLS,
|
||||||
|
'xlt' => Excel::XLS,
|
||||||
|
'ods' => Excel::ODS,
|
||||||
|
'ots' => Excel::ODS,
|
||||||
|
'slk' => Excel::SLK,
|
||||||
|
'xml' => Excel::XML,
|
||||||
|
'gnumeric' => Excel::GNUMERIC,
|
||||||
|
'htm' => Excel::HTML,
|
||||||
|
'html' => Excel::HTML,
|
||||||
|
'csv' => Excel::CSV,
|
||||||
|
'tsv' => Excel::TSV,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| PDF Extension
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure here which Pdf driver should be used by default.
|
||||||
|
| Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'pdf' => Excel::DOMPDF,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Value Binder
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| PhpSpreadsheet offers a way to hook into the process of a value being
|
||||||
|
| written to a cell. In there some assumptions are made on how the
|
||||||
|
| value should be formatted. If you want to change those defaults,
|
||||||
|
| you can implement your own default value binder.
|
||||||
|
|
|
||||||
|
| Possible value binders:
|
||||||
|
|
|
||||||
|
| [x] Maatwebsite\Excel\DefaultValueBinder::class
|
||||||
|
| [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class
|
||||||
|
| [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'value_binder' => [
|
||||||
|
'default' => Maatwebsite\Excel\DefaultValueBinder::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default cell caching driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default PhpSpreadsheet keeps all cell values in memory, however when
|
||||||
|
| dealing with large files, this might result into memory issues. If you
|
||||||
|
| want to mitigate that, you can configure a cell caching driver here.
|
||||||
|
| When using the illuminate driver, it will store each value in the
|
||||||
|
| cache store. This can slow down the process, because it needs to
|
||||||
|
| store each value. You can use the "batch" store if you want to
|
||||||
|
| only persist to the store when the memory limit is reached.
|
||||||
|
|
|
||||||
|
| Drivers: memory|illuminate|batch
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'driver' => 'memory',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Batch memory caching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When dealing with the "batch" caching driver, it will only
|
||||||
|
| persist to the store when the memory limit is reached.
|
||||||
|
| Here you can tweak the memory limit to your liking.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'batch' => [
|
||||||
|
'memory_limit' => 60000,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Illuminate cache
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "illuminate" caching driver, it will automatically use
|
||||||
|
| your default cache store. However if you prefer to have the cell
|
||||||
|
| cache on a separate store, you can configure the store name here.
|
||||||
|
| You can use any store defined in your cache config. When leaving
|
||||||
|
| at "null" it will use the default store.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'illuminate' => [
|
||||||
|
'store' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Time-to-live (TTL)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The TTL of items written to cache. If you want to keep the items cached
|
||||||
|
| indefinitely, set this to null. Otherwise, set a number of seconds,
|
||||||
|
| a \DateInterval, or a callable.
|
||||||
|
|
|
||||||
|
| Allowable types: callable|\DateInterval|int|null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'default_ttl' => 10800,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Transaction Handler
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default the import is wrapped in a transaction. This is useful
|
||||||
|
| for when an import may fail and you want to retry it. With the
|
||||||
|
| transactions, the previous import gets rolled-back.
|
||||||
|
|
|
||||||
|
| You can disable the transaction handler by setting this to null.
|
||||||
|
| Or you can choose a custom made transaction handler here.
|
||||||
|
|
|
||||||
|
| Supported handlers: null|db
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'transactions' => [
|
||||||
|
'handler' => 'db',
|
||||||
|
'db' => [
|
||||||
|
'connection' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'temporary_files' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Local Temporary Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When exporting and importing files, we use a temporary file, before
|
||||||
|
| storing reading or downloading. Here you can customize that path.
|
||||||
|
| permissions is an array with the permission flags for the directory (dir)
|
||||||
|
| and the create file (file).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'local_path' => storage_path('framework/cache/laravel-excel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Local Temporary Path Permissions
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Permissions is an array with the permission flags for the directory (dir)
|
||||||
|
| and the create file (file).
|
||||||
|
| If omitted the default permissions of the filesystem will be used.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'local_permissions' => [
|
||||||
|
// 'dir' => 0755,
|
||||||
|
// 'file' => 0644,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Remote Temporary Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When dealing with a multi server setup with queues in which you
|
||||||
|
| cannot rely on having a shared local temporary path, you might
|
||||||
|
| want to store the temporary file on a shared disk. During the
|
||||||
|
| queue executing, we'll retrieve the temporary file from that
|
||||||
|
| location instead. When left to null, it will always use
|
||||||
|
| the local path. This setting only has effect when using
|
||||||
|
| in conjunction with queued imports and exports.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'remote_disk' => env('TEMP_UPLOAD_DISK', null),
|
||||||
|
'remote_prefix' => 'excel-tmp',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Force Resync
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When dealing with a multi server setup as above, it's possible
|
||||||
|
| for the clean up that occurs after entire queue has been run to only
|
||||||
|
| cleanup the server that the last AfterImportJob runs on. The rest of the server
|
||||||
|
| would still have the local temporary file stored on it. In this case your
|
||||||
|
| local storage limits can be exceeded and future imports won't be processed.
|
||||||
|
| To mitigate this you can set this config value to be true, so that after every
|
||||||
|
| queued chunk is processed the local temporary file is deleted on the server that
|
||||||
|
| processed it.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'force_resync_remote' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
+1
-1
@@ -144,7 +144,7 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'features' => [
|
'features' => [
|
||||||
Features::registration(),
|
env('REGISTRATION_ENABLED', true) ? Features::registration() : null,
|
||||||
Features::resetPasswords(),
|
Features::resetPasswords(),
|
||||||
Features::emailVerification(),
|
Features::emailVerification(),
|
||||||
Features::updateProfileInformation(),
|
Features::updateProfileInformation(),
|
||||||
|
|||||||
@@ -13,5 +13,7 @@ return [
|
|||||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'self_hosted' => env('SELF_HOSTED', true)
|
'self_hosted' => env('SELF_HOSTED', true),
|
||||||
|
|
||||||
|
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00')
|
||||||
];
|
];
|
||||||
+2
-2
@@ -64,7 +64,7 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'temporary_file_upload' => [
|
'temporary_file_upload' => [
|
||||||
'disk' => null, // Example: 'local', 's3' | Default: 'default'
|
'disk' => env('TEMP_UPLOAD_DISK', null), // Example: 'local', 's3' | Default: 'default'
|
||||||
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
|
||||||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||||
@@ -143,7 +143,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'inject_morph_markers' => true,
|
'inject_morph_markers' => false,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|---------------------------------------------------------------------------
|
|---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| OpenAI API Key and Organization
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify your OpenAI API Key and organization. This will be
|
||||||
|
| used to authenticate with the OpenAI API - you can find your API key
|
||||||
|
| and organization on your OpenAI dashboard, at https://openai.com.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'api_key' => env('OPENAI_API_KEY'),
|
||||||
|
'organization' => env('OPENAI_ORGANIZATION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Request Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The timeout may be used to specify the maximum number of seconds to wait
|
||||||
|
| for a response. By default, the client will time out after 30 seconds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'request_timeout' => env('OPENAI_REQUEST_TIMEOUT', 30),
|
||||||
|
|
||||||
|
//
|
||||||
|
'base_uri' => env('OPENAI_BASE_URI', 'api.openai.com/v1'),
|
||||||
|
'model' => env('OPENAI_MODEL', 'gpt-4o'),
|
||||||
|
];
|
||||||
@@ -35,4 +35,40 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'github' => [
|
||||||
|
'client_id' => env('GITHUB_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GITHUB_CLIENT_SECRET'),
|
||||||
|
'redirect' => '/auth/github/callback',
|
||||||
|
'logo' => 'github-icon',
|
||||||
|
'color' => '#393939',
|
||||||
|
'name' => 'GitHub'
|
||||||
|
],
|
||||||
|
|
||||||
|
'google' => [
|
||||||
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
|
'redirect' => '/auth/google/callback',
|
||||||
|
'color' => '#4285F4',
|
||||||
|
'name' => 'Google'
|
||||||
|
],
|
||||||
|
|
||||||
|
'facebook' => [
|
||||||
|
'client_id' => env('FACEBOOK_CLIENT_ID'),
|
||||||
|
'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
|
||||||
|
'redirect' => '/auth/facebook/callback',
|
||||||
|
'color' => '#0165E1',
|
||||||
|
'name' => 'Facebook'
|
||||||
|
],
|
||||||
|
|
||||||
|
'linkedin-openid' => [
|
||||||
|
'client_id' => env('LINKEDIN_CLIENT_ID'),
|
||||||
|
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
|
||||||
|
'redirect' => '/auth/linkedin-openid/callback',
|
||||||
|
'color' => '#0a66c2',
|
||||||
|
'name' => 'Linkedin'
|
||||||
|
],
|
||||||
|
|
||||||
|
//
|
||||||
|
'enabled_login_providers' => env('ENABLED_LOGIN_PROVIDERS', ''),
|
||||||
|
'ai_chat_enabled' => env('AI_CHAT_ENABLED', false)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ return new class extends Migration
|
|||||||
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
$table->foreignIdFor(Portfolio::class, 'portfolio_id')->constrained()->onDelete('cascade');
|
||||||
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
||||||
$table->boolean('owner')->default(false);
|
$table->boolean('owner')->default(false);
|
||||||
$table->boolean('write')->default(false);
|
$table->boolean('full_access')->default(false);
|
||||||
|
$table->datetime('invite_accepted_at')->nullable();
|
||||||
$table->primary(['portfolio_id', 'user_id']);
|
$table->primary(['portfolio_id', 'user_id']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('holdings', function (Blueprint $table) {
|
||||||
|
$table->boolean('reinvest_dividends')->nullable()->after('quantity');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->boolean('reinvested_dividend')->nullable()->after('split');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('holdings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('reinvest_dividends');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('transactions', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('reinvested_dividend');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('password')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('connected_accounts', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('provider');
|
||||||
|
$table->string('provider_id');
|
||||||
|
$table->string('token', 1000);
|
||||||
|
$table->string('secret')->nullable(); // OAuth1
|
||||||
|
$table->string('refresh_token', 1000)->nullable(); // OAuth2
|
||||||
|
$table->dateTime('expires_at')->nullable(); // OAuth2
|
||||||
|
$table->dateTime('verified_at')->nullable(); // OAuth2
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'id']);
|
||||||
|
$table->index(['provider', 'provider_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
Schema::dropIfExists('connected_accounts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('backup_import_jobs', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('status');
|
||||||
|
$table->string('message');
|
||||||
|
$table->boolean('has_errors')->default(false);
|
||||||
|
$table->dateTime('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('backup_import_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Builder;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class CreateAiChatsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Builder::morphUsingUuids();
|
||||||
|
|
||||||
|
Schema::create('ai_chats', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->foreignIdFor(User::class, 'user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->morphs('chatable');
|
||||||
|
$table->string('role');
|
||||||
|
$table->text('content');
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ai_chats');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('admin')->nullable()->after('profile_photo_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('admin');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,7 +18,8 @@ class MarketDataSeeder extends Seeder
|
|||||||
$chunkSize = 500;
|
$chunkSize = 500;
|
||||||
|
|
||||||
// Path to the CSV file
|
// Path to the CSV file
|
||||||
$csvFilePath = storage_path('app/market_data_seed.csv');
|
// $csvFilePath = storage_path('app/market_data_seed.csv');
|
||||||
|
$csvFilePath = realpath(__DIR__.'/market_data_seed.csv');
|
||||||
|
|
||||||
// Open the file in read mode
|
// Open the file in read mode
|
||||||
if (($handle = fopen($csvFilePath, 'r')) !== false) {
|
if (($handle = fopen($csvFilePath, 'r')) !== false) {
|
||||||
|
|||||||
|
Can't render this file because it is too large.
|
+1
-1
@@ -32,7 +32,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.4
|
image: mysql:8.0
|
||||||
container_name: investbrain-mysql
|
container_name: investbrain-mysql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+56
-4
@@ -11,12 +11,17 @@
|
|||||||
"Log in": "Log in",
|
"Log in": "Log in",
|
||||||
"Register": "Register",
|
"Register": "Register",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
|
"Update": "Update",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Save": "Save",
|
"Save": "Save",
|
||||||
"Close": "Close",
|
"Close": "Close",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
|
"Yes": "Yes",
|
||||||
|
"you": "you",
|
||||||
|
"You": "You",
|
||||||
"Nothing to show here yet": "Nothing to show here yet",
|
"Nothing to show here yet": "Nothing to show here yet",
|
||||||
|
"Try again": "Try again",
|
||||||
|
|
||||||
"Hang on! You're doing that too much.": "Hang on! You're doing that too much.",
|
"Hang on! You're doing that too much.": "Hang on! You're doing that too much.",
|
||||||
"Delete Account": "Delete Account",
|
"Delete Account": "Delete Account",
|
||||||
@@ -49,6 +54,9 @@
|
|||||||
"Token Name": "Token Name",
|
"Token Name": "Token Name",
|
||||||
"Permissions": "Permissions",
|
"Permissions": "Permissions",
|
||||||
"Profile Information": "Profile Information",
|
"Profile Information": "Profile Information",
|
||||||
|
"Your :provider account has been connected.": "Your :provider account has been connected.",
|
||||||
|
"Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.",
|
||||||
|
"Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.",
|
||||||
"Update your account\\'s profile information and email address.": "Update your account\\'s profile information and email address.",
|
"Update your account\\'s profile information and email address.": "Update your account\\'s profile information and email address.",
|
||||||
"Photo": "Photo",
|
"Photo": "Photo",
|
||||||
"Select A New Photo": "Select A New Photo",
|
"Select A New Photo": "Select A New Photo",
|
||||||
@@ -78,7 +86,8 @@
|
|||||||
"I agree to the :terms_of_service and :privacy_policy": "I agree to the :terms_of_service and :privacy_policy",
|
"I agree to the :terms_of_service and :privacy_policy": "I agree to the :terms_of_service and :privacy_policy",
|
||||||
"Terms of Service": "Terms of Service",
|
"Terms of Service": "Terms of Service",
|
||||||
"Privacy Policy": "Privacy Notice",
|
"Privacy Policy": "Privacy Notice",
|
||||||
"Need to register?": "Need to register?",
|
"Sign up with email": "Sign up with email",
|
||||||
|
"Login with": "Login with",
|
||||||
"Already registered?": "Already registered?",
|
"Already registered?": "Already registered?",
|
||||||
"Reset Password": "Reset Password",
|
"Reset Password": "Reset Password",
|
||||||
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Please confirm access to your account by entering the authentication code provided by your authenticator application.",
|
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Please confirm access to your account by entering the authentication code provided by your authenticator application.",
|
||||||
@@ -116,6 +125,11 @@
|
|||||||
"Total Market Value": "Total Market Value",
|
"Total Market Value": "Total Market Value",
|
||||||
"Realized Gain/Loss": "Realized Gain/Loss",
|
"Realized Gain/Loss": "Realized Gain/Loss",
|
||||||
"Dividends Earned": "Dividends Earned",
|
"Dividends Earned": "Dividends Earned",
|
||||||
|
"Dividends": "Dividends",
|
||||||
|
"Holding Options": "Holding Options",
|
||||||
|
"Holding options saved": "Holding options saved",
|
||||||
|
"Reinvest Dividends": "Reinvest Dividends",
|
||||||
|
"Automatically generate buy transactions for any dividends earned": "Automatically generate buy transactions for any dividends earned",
|
||||||
"Split": "Split",
|
"Split": "Split",
|
||||||
"Splits": "Splits",
|
"Splits": "Splits",
|
||||||
"No splits for :symbol yet": "No splits for :symbol yet",
|
"No splits for :symbol yet": "No splits for :symbol yet",
|
||||||
@@ -125,7 +139,8 @@
|
|||||||
"Wishlist": "Wishlist",
|
"Wishlist": "Wishlist",
|
||||||
"Top performers": "Top performers",
|
"Top performers": "Top performers",
|
||||||
"Top headlines": "Top headlines",
|
"Top headlines": "Top headlines",
|
||||||
"Press :key to search": "Press :key to search",
|
"Click or press :key to search": "Click or press :key to search",
|
||||||
|
"Click to search": "Click to search",
|
||||||
"Search holdings, portfolios, or anything else...": "Search holdings, portfolios, or anything else...",
|
"Search holdings, portfolios, or anything else...": "Search holdings, portfolios, or anything else...",
|
||||||
"Darn! Nothing found for that search.": "Darn! Nothing found for that search.",
|
"Darn! Nothing found for that search.": "Darn! Nothing found for that search.",
|
||||||
"Portfolio": "Portfolio",
|
"Portfolio": "Portfolio",
|
||||||
@@ -175,8 +190,9 @@
|
|||||||
"Performance": "Performance",
|
"Performance": "Performance",
|
||||||
"Reset chart": "Reset chart",
|
"Reset chart": "Reset chart",
|
||||||
"Choose time period": "Choose time period",
|
"Choose time period": "Choose time period",
|
||||||
"Edit Portfolio": "Edit Portfolio",
|
"Manage Portfolio": "Manage Portfolio",
|
||||||
"Create Transaction": "Create Transaction",
|
"Create Transaction": "Create Transaction",
|
||||||
|
"Manage Transaction": "Manage Transaction",
|
||||||
"Holding": "Holding",
|
"Holding": "Holding",
|
||||||
"Holdings": "Holdings",
|
"Holdings": "Holdings",
|
||||||
"Recent activity": "Recent activity",
|
"Recent activity": "Recent activity",
|
||||||
@@ -324,5 +340,41 @@
|
|||||||
|
|
||||||
"auth.failed": "These credentials do not match our records.",
|
"auth.failed": "These credentials do not match our records.",
|
||||||
"auth.password": "The provided password is incorrect.",
|
"auth.password": "The provided password is incorrect.",
|
||||||
"auth.throttle": "Too many login attempts. Please try again in :seconds seconds."
|
"auth.throttle": "Too many login attempts. Please try again in :seconds seconds.",
|
||||||
|
|
||||||
|
"Add People": "Add People",
|
||||||
|
"People with access": "People with access",
|
||||||
|
"Owner": "Owner",
|
||||||
|
"Read only": "Read only",
|
||||||
|
"Full access": "Full access",
|
||||||
|
"You do not have permission to manage transactions for this portfolio": "You do not have permission to manage transactions for this portfolio",
|
||||||
|
"Updated user's access permission to portfolio": "Updated user's access permission to portfolio",
|
||||||
|
"Removed user's access to portfolio": "Removed user's access to portfolio",
|
||||||
|
"Shared portfolio with user": "Shared portfolio with user",
|
||||||
|
"Share Portfolio": "Share Portfolio",
|
||||||
|
"Type an email address to share portfolio": "Type an email address to share portfolio",
|
||||||
|
"Grant full access": "Grant full access",
|
||||||
|
"Allow this user to manage portfolio details and create or update transactions": "Allow this user to manage portfolio details and create or update transactions",
|
||||||
|
"Share": "Share",
|
||||||
|
"Remove Access": "Remove Access",
|
||||||
|
"By removing this person's access, they will no longer be able to view this portfolio. They will lose access immediately.": "By removing this person's access, they will no longer be able to view this portfolio. They will lose access immediately.",
|
||||||
|
|
||||||
|
"Hey again!": "Hey again!",
|
||||||
|
"Before you can get started with Investbrain, let's complete your profile:": "Before you can get started with Investbrain, let's complete your profile:",
|
||||||
|
"Get Started": "Get Started",
|
||||||
|
|
||||||
|
"You do not have access to that portfolio.": "You do not have access to that portfolio.",
|
||||||
|
"Import starting...": "Import starting...",
|
||||||
|
"Import is in progress...": "Import is in progress...",
|
||||||
|
"Importing portfolios...": "Importing portfolios...",
|
||||||
|
"Importing transactions...": "Importing transactions...",
|
||||||
|
"Importing daily changes...": "Importing daily changes...",
|
||||||
|
"Import completed successfully!": "Import completed successfully!",
|
||||||
|
"Your import will continue in the background": "Your import will continue in the background",
|
||||||
|
|
||||||
|
"AI Chat": "AI Chat",
|
||||||
|
"Hi, how can I help?": "Hi, how can I help?",
|
||||||
|
"Have a question? AI might be able to help...": "Have a question? AI might be able to help...",
|
||||||
|
"Feel free to ask me a question!": "Feel free to ask me a question!",
|
||||||
|
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor."
|
||||||
}
|
}
|
||||||
+56
-4
@@ -11,12 +11,17 @@
|
|||||||
"Log in": "Iniciar sesión",
|
"Log in": "Iniciar sesión",
|
||||||
"Register": "Registrarse",
|
"Register": "Registrarse",
|
||||||
"Create": "Crear",
|
"Create": "Crear",
|
||||||
|
"Update": "Actualizar",
|
||||||
"Cancel": "Cancelar",
|
"Cancel": "Cancelar",
|
||||||
"Save": "Guardar",
|
"Save": "Guardar",
|
||||||
"Close": "Cerrar",
|
"Close": "Cerrar",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"and": "y",
|
"and": "y",
|
||||||
|
"Yes": "Sí",
|
||||||
|
"you": "tú",
|
||||||
|
"You": "Tú",
|
||||||
"Nothing to show here yet": "No hay nada que mostrar aquí todavía",
|
"Nothing to show here yet": "No hay nada que mostrar aquí todavía",
|
||||||
|
"Try again": "Intentar otra vez",
|
||||||
|
|
||||||
"Hang on! You're doing that too much.": "¡Por favor espere un momento!",
|
"Hang on! You're doing that too much.": "¡Por favor espere un momento!",
|
||||||
"Delete Account": "Eliminar Cuenta",
|
"Delete Account": "Eliminar Cuenta",
|
||||||
@@ -49,6 +54,9 @@
|
|||||||
"Token Name": "Nombre del Token",
|
"Token Name": "Nombre del Token",
|
||||||
"Permissions": "Permisos",
|
"Permissions": "Permisos",
|
||||||
"Profile Information": "Información del Perfil",
|
"Profile Information": "Información del Perfil",
|
||||||
|
"Your :provider account has been connected.": "Su cuenta :provider ha sido conectada.",
|
||||||
|
"Account already exists. Check your email to connect your :provider account.": "La cuenta ya existe. Revisa tu correo electrónico para conectar tu cuenta :provider.",
|
||||||
|
"Could not login using :provider. Try again later.": "No se pudo iniciar sesión con :provider. Inténtalo nuevamente más tarde.",
|
||||||
"Update your account's profile information and email address.": "Actualiza la información de perfil y la dirección de correo electrónico de tu cuenta.",
|
"Update your account's profile information and email address.": "Actualiza la información de perfil y la dirección de correo electrónico de tu cuenta.",
|
||||||
"Photo": "Foto",
|
"Photo": "Foto",
|
||||||
"Select A New Photo": "Seleccionar una Nueva Foto",
|
"Select A New Photo": "Seleccionar una Nueva Foto",
|
||||||
@@ -78,7 +86,8 @@
|
|||||||
"I agree to the :terms_of_service and :privacy_policy": "Acepto los :terms_of_service y la :privacy_policy",
|
"I agree to the :terms_of_service and :privacy_policy": "Acepto los :terms_of_service y la :privacy_policy",
|
||||||
"Terms of Service": "Términos de Servicio",
|
"Terms of Service": "Términos de Servicio",
|
||||||
"Privacy Policy": "Aviso de Privacidad",
|
"Privacy Policy": "Aviso de Privacidad",
|
||||||
"Need to register?": "¿Necesitas registrarte?",
|
"Sign up with email": "Regístrate con correo electrónico",
|
||||||
|
"Login with": "Iniciar sesión con",
|
||||||
"Already registered?": "¿Ya estás registrado?",
|
"Already registered?": "¿Ya estás registrado?",
|
||||||
"Reset Password": "Restablecer Contraseña",
|
"Reset Password": "Restablecer Contraseña",
|
||||||
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Por favor, confirma el acceso a tu cuenta ingresando el código de autenticación proporcionado por tu aplicación de autenticación.",
|
"Please confirm access to your account by entering the authentication code provided by your authenticator application.": "Por favor, confirma el acceso a tu cuenta ingresando el código de autenticación proporcionado por tu aplicación de autenticación.",
|
||||||
@@ -116,6 +125,11 @@
|
|||||||
"Total Market Value": "Valor Total de Mercado",
|
"Total Market Value": "Valor Total de Mercado",
|
||||||
"Realized Gain/Loss": "Ganancia/Pérdida Realizada",
|
"Realized Gain/Loss": "Ganancia/Pérdida Realizada",
|
||||||
"Dividends Earned": "Dividendos Ganados",
|
"Dividends Earned": "Dividendos Ganados",
|
||||||
|
"Dividends": "Dividendos",
|
||||||
|
"Holding Options": "Opciones de Participaciones",
|
||||||
|
"Holding options saved": "Opciones de participaciones guardadas",
|
||||||
|
"Reinvest Dividends": "Reinvertir Dividendos",
|
||||||
|
"Automatically generate buy transactions for any dividends earned": "Genere automáticamente transacciones de compra para cualquier dividendo obtenido",
|
||||||
"Split": "Division",
|
"Split": "Division",
|
||||||
"Splits": "Divisiones",
|
"Splits": "Divisiones",
|
||||||
"No splits for :symbol yet": "No hay divisiones para :symbol",
|
"No splits for :symbol yet": "No hay divisiones para :symbol",
|
||||||
@@ -125,7 +139,8 @@
|
|||||||
"Wishlist": "Lista de deseos",
|
"Wishlist": "Lista de deseos",
|
||||||
"Top performers": "Mejores rendimientos",
|
"Top performers": "Mejores rendimientos",
|
||||||
"Top headlines": "Principales titulares",
|
"Top headlines": "Principales titulares",
|
||||||
"Press :key to search": "Presiona :key para buscar",
|
"Click or press :key to search": "Haz clic o presiona :key para buscar",
|
||||||
|
"Click to search": "Haz clic para buscar",
|
||||||
"Search holdings, portfolios, or anything else...": "Busca participaciones, portafolios, o cualquier otra cosa...",
|
"Search holdings, portfolios, or anything else...": "Busca participaciones, portafolios, o cualquier otra cosa...",
|
||||||
"Darn! Nothing found for that search.": "¡Vaya! No se encontró nada para esa búsqueda.",
|
"Darn! Nothing found for that search.": "¡Vaya! No se encontró nada para esa búsqueda.",
|
||||||
"Portfolio": "Portafolio",
|
"Portfolio": "Portafolio",
|
||||||
@@ -175,8 +190,9 @@
|
|||||||
"Performance": "Desempeño",
|
"Performance": "Desempeño",
|
||||||
"Reset chart": "Restablecer gráfico",
|
"Reset chart": "Restablecer gráfico",
|
||||||
"Choose time period": "Elegir período de tiempo",
|
"Choose time period": "Elegir período de tiempo",
|
||||||
"Edit Portfolio": "Editar Portafolio",
|
"Manage Portfolio": "Editar Portafolio",
|
||||||
"Create Transaction": "Crear Transacción",
|
"Create Transaction": "Crear Transacción",
|
||||||
|
"Manage Transaction": "Editar Transacción",
|
||||||
"Holding": "Tenencia",
|
"Holding": "Tenencia",
|
||||||
"Holdings": "Tenencias",
|
"Holdings": "Tenencias",
|
||||||
"Recent activity": "Actividad reciente",
|
"Recent activity": "Actividad reciente",
|
||||||
@@ -324,5 +340,41 @@
|
|||||||
|
|
||||||
"auth.failed": "Estas credenciales no coinciden con nuestros registros.",
|
"auth.failed": "Estas credenciales no coinciden con nuestros registros.",
|
||||||
"auth.password": "La contraseña es incorrecta.",
|
"auth.password": "La contraseña es incorrecta.",
|
||||||
"auth.throttle": "Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos."
|
"auth.throttle": "Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos.",
|
||||||
|
|
||||||
|
"Add people": "Agregar Personas",
|
||||||
|
"People with access": "Personas con acceso",
|
||||||
|
"Read only": "Sólo lectura",
|
||||||
|
"Full access": "Acceso completo",
|
||||||
|
"Owner": "Dueño",
|
||||||
|
"You do not have permission to manage transactions for this portfolio": "No tienes permisos para administrar transacciones",
|
||||||
|
"Updated user's access permission to portfolio": "Se actualizó el permiso de acceso del usuario al portafolio",
|
||||||
|
"Removed user's access to portfolio": "Se eliminó el acceso del usuario al portafolio",
|
||||||
|
"Shared portfolio with user": "Portafolio compartido con usuario",
|
||||||
|
"Share Portfolio": "Compartir Portafolio",
|
||||||
|
"Type an email address to share portfolio": "Escribe una dirección de correo electrónico para compartir portafolio",
|
||||||
|
"Grant full access": "Otorgar acceso completo",
|
||||||
|
"Allow this user to manage portfolio details and create or update transactions": "Permitir a este usuario administrar detalles de portafolio y crear o actualizar transacciones",
|
||||||
|
"Share": "Compartir",
|
||||||
|
"Remove Access": "Eliminar acceso",
|
||||||
|
"By removing this person's access, they will no longer be able to view this portfolio. They will lose access immediately.": "Al eliminar el acceso de esta persona, ya no podrá ver este portafolio. Perderán el acceso inmediatamente.",
|
||||||
|
|
||||||
|
"Hey again!": "¡Oye de nuevo!",
|
||||||
|
"Before you can get started with Investbrain, let's complete your profile:": "Antes de poder comenzar a utilizar Investbrain, deberá crear una cuenta:",
|
||||||
|
"Get Started": "Crear Contraseña",
|
||||||
|
|
||||||
|
"You do not have access to that portfolio.": "No tienes acceso a ese portafolio.",
|
||||||
|
"Import starting...": "Iniciando la importación...",
|
||||||
|
"Import is in progress...": "La importación está en progreso...",
|
||||||
|
"Importing portfolios...": "Importando portafolios...",
|
||||||
|
"Importing transactions...": "Importando transacciones...",
|
||||||
|
"Importing daily changes...": "Importando cambios diarios...",
|
||||||
|
"Import completed successfully!": "¡La importación se completó con éxito!",
|
||||||
|
"Your import will continue in the background": "La importación continuará en segundo plano",
|
||||||
|
|
||||||
|
"AI Chat": "Chat de AI",
|
||||||
|
"Hi, how can I help?": "Hola, ¿cómo puedo ayudarte?",
|
||||||
|
"Have a question? AI might be able to help...": "¿Tienes una pregunta? La AI podría ayudarte...",
|
||||||
|
"Feel free to ask me a question!": "¡No dudes en hacerme una pregunta!",
|
||||||
|
"Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.": "Los consejos generados por AI pueden contener errores. Úsalos bajo tu propio riesgo. Consulta siempre a un asesor de inversiones con licencia."
|
||||||
}
|
}
|
||||||
Generated
+25
-21
@@ -11,12 +11,12 @@
|
|||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"axios": "^1.6.4",
|
"axios": "^1.7.4",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
"vite": "^5.0"
|
"vite": "^5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -862,9 +862,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
|
||||||
"integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==",
|
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
@@ -1721,9 +1721,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.0.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -1757,9 +1757,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.40",
|
"version": "8.4.47",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||||
"integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
|
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1777,8 +1777,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"picocolors": "^1.0.1",
|
"picocolors": "^1.1.0",
|
||||||
"source-map-js": "^1.2.0"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
@@ -2090,9 +2090,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2437,14 +2437,14 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.3.5",
|
"version": "5.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
||||||
"integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
|
"integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.43",
|
||||||
"rollup": "^4.13.0"
|
"rollup": "^4.20.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -2463,6 +2463,7 @@
|
|||||||
"less": "*",
|
"less": "*",
|
||||||
"lightningcss": "^1.21.0",
|
"lightningcss": "^1.21.0",
|
||||||
"sass": "*",
|
"sass": "*",
|
||||||
|
"sass-embedded": "*",
|
||||||
"stylus": "*",
|
"stylus": "*",
|
||||||
"sugarss": "*",
|
"sugarss": "*",
|
||||||
"terser": "^5.4.0"
|
"terser": "^5.4.0"
|
||||||
@@ -2480,6 +2481,9 @@
|
|||||||
"sass": {
|
"sass": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"stylus": {
|
"stylus": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -9,12 +9,12 @@
|
|||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"axios": "^1.6.4",
|
"axios": "^1.7.4",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
"vite": "^5.0"
|
"vite": "^5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apexcharts": "^3.51.0"
|
"apexcharts": "^3.51.0"
|
||||||
|
|||||||
@@ -5,3 +5,34 @@
|
|||||||
[x-cloak] {
|
[x-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-chat ul, .ai-chat ol, .ai-chat ol li > ul {
|
||||||
|
margin-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat ul > li {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat ol > li {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat ol li li {
|
||||||
|
padding-left: 0rem;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat ol li li::before {
|
||||||
|
content: " - ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat li, .ai-chat p {
|
||||||
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat code {
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Privacy Notice
|
# Privacy Notice
|
||||||
|
|
||||||
__Last updated: August 23, 2024__
|
__Last updated: October 25, 2024__
|
||||||
|
|
||||||
Your privacy is important to us. This Privacy Notice describes Investbrain’s (also referred to as “we” or “our”) practices on collection, processing, and disclosure of your information when you visit our website or use our application (collectively referred to as “Services”). This Privacy Notice also tells you about your rights and how the law protects you.
|
Your privacy is important to us. This Privacy Notice describes Investbrain’s (also referred to as “we” or “our”) practices on collection, processing, and disclosure of your information when you visit our website or use our application (collectively referred to as “Services”). This Privacy Notice also tells you about your rights and how the law protects you.
|
||||||
|
|
||||||
@@ -74,9 +74,8 @@ In addition, we genuinely value the assistance of security researchers and any o
|
|||||||
- We will define the severity of the issue based on the impact and the ease of exploitation.
|
- We will define the severity of the issue based on the impact and the ease of exploitation.
|
||||||
- We may take 3 to 5 days to validate the reported issue.
|
- We may take 3 to 5 days to validate the reported issue.
|
||||||
- Actions will be initiated to fix the vulnerability in accordance with our commitment to security and privacy.
|
- Actions will be initiated to fix the vulnerability in accordance with our commitment to security and privacy.
|
||||||
- When conducting security testing, should not violate our privacy policies, modify/delete unauthenticated user data, disrupt production servers, or to degrade user experience.
|
- When conducting security testing, you should not violate our privacy policies, modify/delete unauthenticated user data, disrupt production servers, or degrade user experience.
|
||||||
- Documenting or publishing the vulnerability details in public domain is against our responsible disclosure policy.
|
- Keep information about any vulnerability confidential until the issue is resolved.
|
||||||
- Keep information about any vulnerability confidential until the issue is resolved
|
|
||||||
|
|
||||||
## Children’s Privacy
|
## Children’s Privacy
|
||||||
No part of the Services is directed to children under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us immediately.
|
No part of the Services is directed to children under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us immediately.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Aviso de Privacidad
|
# Aviso de Privacidad
|
||||||
|
|
||||||
__Última actualización: 23 de agosto de 2024__
|
__Última actualización: 25 de octubre de 2024__
|
||||||
|
|
||||||
Su privacidad es importante para nosotros. Este Aviso de Privacidad describe las prácticas de Investbrain (también referido como “nosotros” o “nuestro”) en cuanto a la recopilación, procesamiento y divulgación de su información cuando visita nuestro sitio web o utiliza nuestra aplicación (colectivamente referidos como “Servicios”). Este Aviso de Privacidad también le informa sobre sus derechos y cómo la ley protege su información.
|
Su privacidad es importante para nosotros. Este Aviso de Privacidad describe las prácticas de Investbrain (también referido como “nosotros” o “nuestro”) en cuanto a la recopilación, procesamiento y divulgación de su información cuando visita nuestro sitio web o utiliza nuestra aplicación (colectivamente referidos como “Servicios”). Este Aviso de Privacidad también le informa sobre sus derechos y cómo la ley protege su información.
|
||||||
|
|
||||||
@@ -76,7 +76,6 @@ Además, valoramos genuinamente la asistencia de los investigadores de seguridad
|
|||||||
- Podemos tardar de 3 a 5 días en validar el problema reportado.
|
- Podemos tardar de 3 a 5 días en validar el problema reportado.
|
||||||
- Se iniciarán acciones para corregir la vulnerabilidad de acuerdo con nuestro compromiso con la seguridad y privacidad.
|
- Se iniciarán acciones para corregir la vulnerabilidad de acuerdo con nuestro compromiso con la seguridad y privacidad.
|
||||||
- Al realizar pruebas de seguridad, no debe violar nuestras políticas de privacidad, modificar/eliminar datos de usuarios no autenticados, interrumpir los servidores de producción o degradar la experiencia del usuario.
|
- Al realizar pruebas de seguridad, no debe violar nuestras políticas de privacidad, modificar/eliminar datos de usuarios no autenticados, interrumpir los servidores de producción o degradar la experiencia del usuario.
|
||||||
- Documentar o publicar detalles de la vulnerabilidad en dominios públicos va en contra de nuestra política de divulgación responsable.
|
|
||||||
- Mantenga la confidencialidad de la información sobre cualquier vulnerabilidad hasta que el problema sea resuelto.
|
- Mantenga la confidencialidad de la información sobre cualquier vulnerabilidad hasta que el problema sea resuelto.
|
||||||
|
|
||||||
## Privacidad de los niños
|
## Privacidad de los niños
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
</x-forms.form-section>
|
</x-forms.form-section>
|
||||||
|
|
||||||
@if ($this->user->tokens->isNotEmpty())
|
@if ($this->user->tokens->isNotEmpty())
|
||||||
<x-section-border />
|
<x-section-border hide-on-mobile />
|
||||||
|
|
||||||
<!-- Manage API Tokens -->
|
<!-- Manage API Tokens -->
|
||||||
<div class="mt-10 sm:mt-0">
|
<div class="mt-10 sm:mt-0">
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@session('status')
|
@session('status')
|
||||||
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
|
<x-alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ $value }}
|
{{ $value }}
|
||||||
</div>
|
</x-alert>
|
||||||
@endsession
|
@endsession
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-errors class="mb-4" />
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
<x-button class="btn-primary">
|
<x-button class="btn-primary" type="submit">
|
||||||
{{ __('Email Password Reset Link') }}
|
{{ __('Email Password Reset Link') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<x-guest-layout>
|
||||||
|
<x-authentication-card>
|
||||||
|
<x-slot:logo>
|
||||||
|
<div class="w-24 mb-10">
|
||||||
|
<x-glyph-only-logo />
|
||||||
|
</div>
|
||||||
|
</x-slot:logo>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-4">{{ __('Hey again!') }} 👋</h1>
|
||||||
|
<p class="mb-2">{{ __('Before you can get started with Investbrain, let\'s complete your profile:') }}</p>
|
||||||
|
|
||||||
|
@livewire('invited-onboarding-form', [
|
||||||
|
'portfolio' => $portfolio,
|
||||||
|
'user' => $user,
|
||||||
|
])
|
||||||
|
|
||||||
|
</x-authentication-card>
|
||||||
|
</x-guest-layout>
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
<x-errors class="mb-4" />
|
<x-errors class="mb-4" />
|
||||||
|
|
||||||
@session('status')
|
@session('status')
|
||||||
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
|
<x-alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ $value }}
|
{{ $value }}
|
||||||
</div>
|
</x-alert>
|
||||||
@endsession
|
@endsession
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login') }}">
|
<form method="POST" action="{{ route('login') }}">
|
||||||
@@ -28,10 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block mt-4">
|
<div class="block mt-4">
|
||||||
<label for="remember_me" class="flex items-center">
|
<x-checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
|
||||||
<x-checkbox id="remember_me" name="remember" />
|
|
||||||
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ __('Remember me') }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
@@ -47,15 +44,20 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-section-border />
|
@if (\Laravel\Fortify\Features::enabled('registration'))
|
||||||
|
|
||||||
<div class="">
|
<x-section-border />
|
||||||
|
|
||||||
|
<x-connected-accounts-login />
|
||||||
|
|
||||||
<x-button link="{{ route('register') }}" class="btn-sm btn-block btn-outline btn-secondary" >
|
<x-button
|
||||||
{{ __('Need to register?') }}
|
link="{{ route('register') }}"
|
||||||
|
class="btn-sm btn-block btn-outline btn-secondary my-1"
|
||||||
|
>
|
||||||
|
{{ __('Sign up with email') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
|
|
||||||
</div>
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-authentication-card>
|
||||||
</x-guest-layout>
|
</x-guest-layout>
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
|
|
||||||
<div class="ms-2 text-sm">
|
<div class="ms-2 text-sm">
|
||||||
{!! __('I agree to the :terms_of_service and :privacy_policy', [
|
{!! __('I agree to the :terms_of_service and :privacy_policy', [
|
||||||
'terms_of_service' => '<a target="_blank" href="'.route('terms.show').'" class="underline">'.__('Terms of Service').'</a>',
|
'terms_of_service' => '<a target="_blank" href="https://investbra.in/terms" class="underline">'.__('Terms of Service').'</a>',
|
||||||
'privacy_policy' => '<a target="_blank" href="'.route('policy.show').'" class="underline">'.__('Privacy Policy').'</a>',
|
'privacy_policy' => '<a target="_blank" href="https://investbra.in/privacy" class="underline">'.__('Privacy Policy').'</a>',
|
||||||
]) !!}
|
]) !!}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
<x-button class="btn-primary">
|
<x-button class="btn-primary" type="submit">
|
||||||
{{ __('Reset Password') }}
|
{{ __('Reset Password') }}
|
||||||
</x-button>
|
</x-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (session('status') == 'verification-link-sent')
|
@if (session('status') == 'verification-link-sent')
|
||||||
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
|
<x-alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
|
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
|
||||||
</div>
|
</x-alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@props(['id' => null, 'maxWidth' => null])
|
@props(['id' => null, 'maxWidth' => null])
|
||||||
|
|
||||||
<x-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }}>
|
<x-ib-livewire-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }} :showClose="false">
|
||||||
<div class="p-4">
|
<div class="p-2">
|
||||||
<div class="sm:flex sm:items-start">
|
<div class="sm:flex sm:items-start">
|
||||||
<div class="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
<div class="mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
@@ -21,9 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-end px-6 py-4 text-end">
|
<div class="flex flex-row items-center justify-end mt-3 p-2 text-end">
|
||||||
{{ $footer }}
|
{{ $footer }}
|
||||||
</div>
|
</div>
|
||||||
</x-modal>
|
</x-ib-livewire-modal>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div>
|
||||||
|
@if(!empty(config('services.enabled_login_providers')))
|
||||||
|
@foreach(explode(',', config('services.enabled_login_providers')) as $provider)
|
||||||
|
<x-button
|
||||||
|
link="{{ route('oauth.redirect', ['provider' => $provider]) }}"
|
||||||
|
class="btn-sm btn-block my-1"
|
||||||
|
style='background-color: {{ config("services.$provider.color") }}'
|
||||||
|
no-wire-navigate
|
||||||
|
>
|
||||||
|
@include("components.$provider-icon")
|
||||||
|
|
||||||
|
{{ __('Login with') }} {{ config("services.$provider.name") }}
|
||||||
|
</x-button>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@props(['id' => null, 'maxWidth' => null])
|
@props(['id' => null, 'maxWidth' => null])
|
||||||
|
|
||||||
<x-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }}>
|
<x-ib-livewire-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }} :showClose="false">
|
||||||
<div class="px-6 py-4">
|
<div class="p-2">
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
{{ $title }}
|
{{ $title }}
|
||||||
</div>
|
</div>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-end px-6 py-4 text-end">
|
<div class="flex flex-row items-center justify-end mt-3 p-2 text-end">
|
||||||
{{ $footer }}
|
{{ $footer }}
|
||||||
</div>
|
</div>
|
||||||
</x-modal>
|
</x-ib-livewire-modal>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
<svg viewBox="0 0 90 90" aria-hidden="true" class="size-6 fill-current">
|
||||||
|
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M72,12L18,12C14.685,12 12,14.685 12,18L12,72C12,75.315 14.685,78 18,78L48,78L48,51L39,51L39,42L48,42L48,37.167C48,28.017 52.458,24 60.063,24C63.705,24 65.631,24.27 66.543,24.393L66.543,33L61.356,33C58.128,33 57,34.704 57,38.154L57,42L66.462,42L65.178,51L57,51L57,78L72,78C75.315,78 78,75.315 78,72L78,18C78,14.685 75.312,12 72,12Z" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
@@ -7,7 +7,7 @@
|
|||||||
} else {
|
} else {
|
||||||
|
|
||||||
$isUp = $costBasis <= $marketValue;
|
$isUp = $costBasis <= $marketValue;
|
||||||
$percent = $costBasis ? (($marketValue - $costBasis) / $costBasis) : 0;
|
$percent = $costBasis ? (($marketValue - $costBasis) / $costBasis) * 100 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="size-6 fill-current"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.463 2 11.97c0 4.404 2.865 8.14 6.839 9.458.5.092.682-.216.682-.48 0-.236-.008-.864-.013-1.695-2.782.602-3.369-1.337-3.369-1.337-.454-1.151-1.11-1.458-1.11-1.458-.908-.618.069-.606.069-.606 1.003.07 1.531 1.027 1.531 1.027.892 1.524 2.341 1.084 2.91.828.092-.643.35-1.083.636-1.332-2.22-.251-4.555-1.107-4.555-4.927 0-1.088.39-1.979 1.029-2.675-.103-.252-.446-1.266.098-2.638 0 0 .84-.268 2.75 1.022A9.607 9.607 0 0 1 12 6.82c.85.004 1.705.114 2.504.336 1.909-1.29 2.747-1.022 2.747-1.022.546 1.372.202 2.386.1 2.638.64.696 1.028 1.587 1.028 2.675 0 3.83-2.339 4.673-4.566 4.92.359.307.678.915.678 1.846 0 1.332-.012 2.407-.012 2.734 0 .267.18.577.688.48 3.97-1.32 6.833-5.054 6.833-9.458C22 6.463 17.522 2 12 2Z"></path></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true" class="size-6 fill-current">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.463 2 11.97c0 4.404 2.865 8.14 6.839 9.458.5.092.682-.216.682-.48 0-.236-.008-.864-.013-1.695-2.782.602-3.369-1.337-3.369-1.337-.454-1.151-1.11-1.458-1.11-1.458-.908-.618.069-.606.069-.606 1.003.07 1.531 1.027 1.531 1.027.892 1.524 2.341 1.084 2.91.828.092-.643.35-1.083.636-1.332-2.22-.251-4.555-1.107-4.555-4.927 0-1.088.39-1.979 1.029-2.675-.103-.252-.446-1.266.098-2.638 0 0 .84-.268 2.75 1.022A9.607 9.607 0 0 1 12 6.82c.85.004 1.705.114 2.504.336 1.909-1.29 2.747-1.022 2.747-1.022.546 1.372.202 2.386.1 2.638.64.696 1.028 1.587 1.028 2.675 0 3.83-2.339 4.673-4.566 4.92.359.307.678.915.678 1.846 0 1.332-.012 2.407-.012 2.734 0 .267.18.577.688.48 3.97-1.32 6.833-5.054 6.833-9.458C22 6.463 17.522 2 12 2Z"></path>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 865 B After Width: | Height: | Size: 871 B |
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
<svg viewBox="0 0 52 52" aria-hidden="true" class="size-6 fill-current">
|
||||||
|
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.996,48C13.313,48 2.992,37.684 2.992,25C2.992,12.316 13.313,2 25.996,2C31.742,2 37.242,4.129 41.488,7.996L42.262,8.703L34.676,16.289L33.973,15.688C31.746,13.781 28.914,12.73 25.996,12.73C19.23,12.73 13.723,18.234 13.723,25C13.723,31.766 19.23,37.27 25.996,37.27C30.875,37.27 34.73,34.777 36.547,30.531L24.996,30.531L24.996,20.176L47.547,20.207L47.715,21C48.891,26.582 47.949,34.793 43.184,40.668C39.238,45.531 33.457,48 25.996,48Z" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 582 B |
@@ -0,0 +1,55 @@
|
|||||||
|
@props([
|
||||||
|
'key' => 'modal',
|
||||||
|
'showClose' => true,
|
||||||
|
'closeOnEscape' => true,
|
||||||
|
'title' => null,
|
||||||
|
'subtitle' => null,
|
||||||
|
'persistent' => false
|
||||||
|
])
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@teleport('body')
|
||||||
|
<dialog
|
||||||
|
x-data="{ open: false }"
|
||||||
|
x-on:toggle-{{ $key }}.window="open = !open"
|
||||||
|
class="relative z-50 w-auto h-auto"
|
||||||
|
@if($closeOnEscape)
|
||||||
|
@keydown.window.escape="open = false"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
<template x-teleport="body">
|
||||||
|
<div x-transition.opacity x-show="open" class="fixed top-0 left-0 z-[99] flex items-center justify-center w-full h-full">
|
||||||
|
<div
|
||||||
|
@if(!$persistent)
|
||||||
|
@click="open=false"
|
||||||
|
@endif
|
||||||
|
class="absolute inset-0 w-full h-full bg-black bg-opacity-40"
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<x-card
|
||||||
|
x-trap.inert.noscroll="open"
|
||||||
|
:title="$title"
|
||||||
|
:subtitle="$subtitle"
|
||||||
|
{{ $attributes->merge(['class' => 'relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
@if ($showClose)
|
||||||
|
<x-button
|
||||||
|
icon="o-x-mark"
|
||||||
|
title="{{ __('Close') }}"
|
||||||
|
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
</x-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</dialog>
|
||||||
|
@endteleport
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
'enabled' => false
|
'enabled' => false
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'colors' => ['#3185FC', '#48435C', '#9792E3', '#00E396', '#B74F6F', ],
|
'colors' => ['#3185FC', '#48435C', '#9792E3', '#00E396', '#B74F6F'],
|
||||||
'stroke' => [
|
'stroke' => [
|
||||||
'curve' => "smooth",
|
'curve' => "smooth",
|
||||||
'width' => 3
|
'width' => 3
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
],
|
],
|
||||||
'yaxis' => [
|
'yaxis' => [
|
||||||
'labels' => [
|
'labels' => [
|
||||||
'offsetX' => 0,
|
'offsetX' => -10,
|
||||||
'offsetY' => -10
|
'offsetY' => -10
|
||||||
],
|
],
|
||||||
'tooltip' => [
|
'tooltip' => [
|
||||||
@@ -93,14 +93,17 @@
|
|||||||
} --}}
|
} --}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.data.yaxis.labels.formatter = function (value) {
|
||||||
|
return `$${value}`
|
||||||
|
}
|
||||||
|
|
||||||
this.data.tooltip = {
|
this.data.tooltip = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
y: {
|
y: {
|
||||||
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
|
formatter: (value, { series, seriesIndex, dataPointIndex, w }) => {
|
||||||
|
|
||||||
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
|
const firstDataPoint = this.data.series[seriesIndex].data[0][1]
|
||||||
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
|
const percentageChange = ((value - firstDataPoint) / firstDataPoint) * 100;
|
||||||
return `${value} (${percentageChange.toFixed(2)}%)`;
|
return `$${parseFloat(value.toFixed(2))} (${percentageChange.toFixed(2)}%)`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,7 @@
|
|||||||
|
|
||||||
// generate custom legend view
|
// generate custom legend view
|
||||||
function renderLegend(chartContext) {
|
function renderLegend(chartContext) {
|
||||||
console.log(chartContext)
|
|
||||||
var legendContainer = document.querySelector('#chart-legend-{{ $name }}');
|
var legendContainer = document.querySelector('#chart-legend-{{ $name }}');
|
||||||
|
|
||||||
if (!legendContainer) return;
|
if (!legendContainer) return;
|
||||||
@@ -128,7 +131,7 @@
|
|||||||
|
|
||||||
var seriesColor = chartContext.w.config.colors[i];
|
var seriesColor = chartContext.w.config.colors[i];
|
||||||
var legendItem = document.createElement('div');
|
var legendItem = document.createElement('div');
|
||||||
legendItem.classList.add('flex', 'items-center', 'm-2', 'cursor-pointer');
|
legendItem.classList.add('flex', 'items-center', 'my-2', 'mr-4', 'text-xs', 'md:text-sm', 'cursor-pointer');
|
||||||
legendItem.setAttribute('data-series-index', i);
|
legendItem.setAttribute('data-series-index', i);
|
||||||
|
|
||||||
var colorBox = document.createElement('span');
|
var colorBox = document.createElement('span');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }}
|
{{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }}
|
||||||
>
|
>
|
||||||
|
|
||||||
<h2 class="text-xl mb-2"> {{ $title }} </h2>
|
<h2 class="text-xl mb-2 flex items-center truncate"> {{ $title }} </h2>
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</x-card>
|
</x-card>
|
||||||
@@ -22,14 +22,23 @@
|
|||||||
|
|
||||||
<div @click="open = false" class="fixed inset-0 bg-black opacity-50"></div>
|
<div @click="open = false" class="fixed inset-0 bg-black opacity-50"></div>
|
||||||
|
|
||||||
<x-card
|
<x-card
|
||||||
:title="$title"
|
{{ $attributes->merge(['class' => 'min-h-screen w-full md:w-3/4 xl:w-3/5 rounded-none px-8 transition overflow-y-scroll']) }}
|
||||||
:subtitle="$subtitle"
|
|
||||||
{{ $attributes->merge(['class' => 'min-h-screen w-11/12 lg:w-1/3 rounded-none px-8 transition']) }}
|
|
||||||
>
|
>
|
||||||
|
@if($title)
|
||||||
|
<x-slot:title>
|
||||||
|
{!! strip_tags($title) !!}
|
||||||
|
</x-slot:title>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($subtitle)
|
||||||
|
<x-slot:subtitle>
|
||||||
|
{!! strip_tags($subtitle) !!}
|
||||||
|
</x-slot:subtitle>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($showClose)
|
@if ($showClose)
|
||||||
<x-button icon="o-x-mark" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
|
<x-button icon="o-x-mark" title="{{ __('Close') }}" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@props([
|
||||||
|
'noSeparator' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
<form
|
||||||
|
{{ $attributes->whereDoesntStartWith('class') }}
|
||||||
|
{{ $attributes->class(['grid grid-flow-row auto-rows-min gap-3']) }}
|
||||||
|
>
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
@if ($actions)
|
||||||
|
@if(!$noSeparator)
|
||||||
|
<x-section-border class="my-3" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
{{ $actions}}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@props([
|
||||||
|
'showClose' => true,
|
||||||
|
'closeOnEscape' => true,
|
||||||
|
'title' => null,
|
||||||
|
'subtitle' => null,
|
||||||
|
'persistent' => false
|
||||||
|
])
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@teleport('body')
|
||||||
|
<dialog
|
||||||
|
{{ $attributes->except('wire:model')->class(["modal"]) }}
|
||||||
|
x-data="{open: @entangle($attributes->wire('model')).live }"
|
||||||
|
:class="{'modal-open !animate-none': open}"
|
||||||
|
:open="open"
|
||||||
|
@if($closeOnEscape)
|
||||||
|
@keydown.escape.window = "$wire.{{ $attributes->wire('model')->value() }} = false"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
<x-card
|
||||||
|
:title="$title"
|
||||||
|
:subtitle="$subtitle"
|
||||||
|
{{ $attributes->merge(['class' => 'modal-box relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
|
||||||
|
>
|
||||||
|
@if ($showClose)
|
||||||
|
<x-button
|
||||||
|
icon="o-x-mark"
|
||||||
|
title="{{ __('Close') }}"
|
||||||
|
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
||||||
|
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" method="dialog">
|
||||||
|
<a
|
||||||
|
@if(!$persistent)
|
||||||
|
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
||||||
|
@endif
|
||||||
|
type="button"
|
||||||
|
title="{{ __('Close') }}"
|
||||||
|
>
|
||||||
|
{{ __('Close') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
@endteleport
|
||||||
|
</div>
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@props([
|
|
||||||
'key' => 'modal',
|
|
||||||
'showClose' => true,
|
|
||||||
'closeOnEscape' => true,
|
|
||||||
'title' => null,
|
|
||||||
'subtitle' => null
|
|
||||||
])
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-data="{ open: false }"
|
|
||||||
x-on:toggle-{{ $key }}.window="open = !open"
|
|
||||||
class="relative z-50 w-auto h-auto"
|
|
||||||
@if($closeOnEscape)
|
|
||||||
@keydown.window.escape="open = false"
|
|
||||||
@endif
|
|
||||||
>
|
|
||||||
<template x-teleport="body">
|
|
||||||
<div x-transition.opacity x-show="open" class="fixed top-0 left-0 z-[99] flex items-center justify-center w-full h-full">
|
|
||||||
<div
|
|
||||||
@click="open=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black bg-opacity-40"
|
|
||||||
x-show="open"
|
|
||||||
x-cloak
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<x-card
|
|
||||||
x-trap.inert.noscroll="open"
|
|
||||||
:title="$title"
|
|
||||||
:subtitle="$subtitle"
|
|
||||||
{{ $attributes->merge(['class' => 'relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
|
|
||||||
x-show="open"
|
|
||||||
x-cloak
|
|
||||||
>
|
|
||||||
@if ($showClose)
|
|
||||||
<x-button
|
|
||||||
icon="o-x-mark"
|
|
||||||
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
</x-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user