├── assets ├── event_list.css ├── account_value.css ├── select_positions.css ├── routing.js ├── components │ ├── IconWithText.js │ ├── position_link.css │ ├── ImportButton.js │ ├── RecordButton.js │ ├── Snackbar.js │ ├── SubmitSpinnerButton.js │ ├── EventTypeDisplay.js │ ├── DatePicker.js │ ├── PositionLink.js │ ├── AreaChart.js │ ├── Stepper.js │ ├── Stepper.test.js │ └── SplitButtonNav.js ├── display_utils.js ├── display_utils.test.js ├── currencies.js ├── index.js ├── transaction_list.css ├── forms │ ├── utils.test.js │ ├── utils.js │ ├── styles.js │ ├── DeleteDialog.js │ └── CreateAccountForm.js ├── App.js ├── error_utils.js ├── SelectPositions.js ├── colors.js ├── AccountValues.js ├── assetOptions.js ├── TimeSelector.js ├── theme.js ├── TransactionImportList.js ├── Reports.js ├── TransactionImportDetail.js ├── Events.js ├── position_list.css ├── Header.js ├── LotList.js ├── TransactionImportResult.js ├── PositionList.js ├── TransactionDetail.test.js └── TransactionImportRecord.js ├── finance ├── __init__.py ├── integrations │ └── __init__.py ├── migrations │ ├── __init__.py │ ├── 0024_alter_lot_sell_date.py │ ├── 0017_alter_asset_tracked.py │ ├── 0014_auto_20210815_1653.py │ ├── 0007_alter_account_balance.py │ ├── 0008_alter_account_last_modified.py │ ├── 0030_alter_transaction_quantity.py │ ├── 0013_alter_transaction_order_id.py │ ├── 0005_position_quantity.py │ ├── 0022_accountevent_withheld_taxes.py │ ├── 0004_alter_transaction_transaction_costs.py │ ├── 0028_alter_transactionimport_integration.py │ ├── 0010_currencyexchangerate_value.py │ ├── 0015_alter_asset_exchange.py │ ├── 0020_accountevent_event_type.py │ ├── 0037_alter_eventimportrecord_transaction.py │ ├── 0027_transactionimport_account.py │ ├── 0035_auto_20220130_1802.py │ ├── 0039_auto_20220226_1240.py │ ├── 0025_auto_20211102_1819.py │ ├── 0012_auto_20210725_1745.py │ ├── 0016_auto_20210822_1041.py │ ├── 0019_auto_20210822_1952.py │ ├── 0011_auto_20210529_0945.py │ ├── 0003_auto_20210427_1419.py │ ├── 0029_auto_20220121_1709.py │ ├── 0021_auto_20210929_1246.py │ ├── 0002_auto_20210426_1145.py │ ├── 0018_auto_20210822_1117.py │ ├── 0032_auto_20220129_1631.py │ ├── 0036_auto_20220202_1101.py │ ├── 0006_auto_20210503_1358.py │ ├── 0034_auto_20220130_1743.py │ ├── 0031_auto_20220122_1352.py │ ├── 0009_currencyexchangerate_pricehistory.py │ ├── 0040_auto_20220402_1814.py │ ├── 0033_eventimportrecord.py │ ├── 0026_transactionimport_transactionimportrecord.py │ ├── 0023_auto_20211028_1835.py │ ├── 0038_auto_20220203_1112.py │ └── 0001_initial.py ├── apps.py ├── binance_transaction_sample_with_income_mini.csv ├── tasks.py ├── binance_transaction_only_usd.csv ├── admin.py ├── transactions_example_latest_bad_columns.csv ├── transactions_example_latest.csv ├── binance_transaction_sample_mismatched_dates.csv ├── transactions_example_latest_renamed.csv ├── management │ └── commands │ │ ├── fetch_prices.py │ │ └── import_transactions.py ├── transactions_example_short.csv ├── binance_transaction_sample_odd.csv ├── binance_transaction_sample_dates_slight_offset.csv ├── binance_transaction_sample.csv ├── utils.py ├── binance_transaction_sample_with_income.csv ├── assets.py ├── testing_utils.py └── test_prices.py ├── invertimo ├── __init__.py ├── celery.py ├── asgi.py ├── wsgi.py ├── views.py └── urls.py ├── static ├── gains.png ├── cupcake.png ├── dollars.jpeg ├── favicon.ico ├── degiro_export.png ├── favicon-16x16.png ├── favicon-32x32.png ├── transactions.png ├── Comfortaa-Medium.ttf ├── OpenSans-Regular.ttf ├── account_overview.png ├── apple-touch-icon.png ├── base_internal.css ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest ├── landing.css └── login.css ├── .gitmodules ├── babel.config.js ├── templates ├── index.html ├── login.html ├── signup.html ├── base_internal.tmpl.html ├── base.html ├── privacy_policy.html └── landing.html ├── deployment ├── app │ ├── docker_entrypoint.sh │ └── docker_entrypoint.dev.sh ├── invertimo.com_docker_compose.service ├── staging.invertimo.com_docker_compose.service ├── setup_remote.sh ├── Readme.md ├── staging.invertimo.com.nginx.conf └── invertimo.com.nginx.conf ├── .dockerignore ├── requirements.in ├── docker-compose.yml ├── .eslintrc.js ├── manage.py ├── setup.cfg ├── docker-compose.prod.yml ├── docker-compose.dev.yml ├── docker-compose.staging.yml ├── Dockerfile ├── LICENSE ├── webpack.config.js ├── README.md ├── .gitignore └── package.json /assets/event_list.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /finance/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /finance/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /finance/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /invertimo/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) -------------------------------------------------------------------------------- /static/gains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/gains.png -------------------------------------------------------------------------------- /static/cupcake.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/cupcake.png -------------------------------------------------------------------------------- /static/dollars.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/dollars.jpeg -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/degiro_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/degiro_export.png -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/transactions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/transactions.png -------------------------------------------------------------------------------- /static/Comfortaa-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/Comfortaa-Medium.ttf -------------------------------------------------------------------------------- /static/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /static/account_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/account_overview.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/base_internal.css: -------------------------------------------------------------------------------- 1 | 2 | .main { 3 | padding: 0; 4 | } 5 | 6 | .button { 7 | padding: 10px; 8 | } -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilonajulczuk/invertimo/HEAD/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deployment/secrets"] 2 | path = deployment/secrets 3 | url = git@github.com:ilonajulczuk/invertimoenv.git 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /assets/account_value.css: -------------------------------------------------------------------------------- 1 | 2 | .account-value-data-chart { 3 | height: 400px; 4 | } 5 | 6 | .account-value-charts { 7 | margin-top: 2em; 8 | margin-bottom: 2em; 9 | } -------------------------------------------------------------------------------- /finance/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FinanceConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'finance' 7 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base_internal.webpack.html' %} 2 | {% load static %} 3 | {% block content %} 4 |
5 | 6 | {{ user.email|json_script:'userEmail' }} 7 | 8 | {% endblock %} -------------------------------------------------------------------------------- /deployment/app/docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | /usr/src/venv/bin/python3.8 manage.py migrate --noinput 3 | /usr/src/venv/bin/python3.8 manage.py collectstatic --noinput 4 | /usr/src/venv/bin/gunicorn -b 0.0.0.0:8000 --workers 4 --timeout 300 invertimo.wsgi -------------------------------------------------------------------------------- /deployment/app/docker_entrypoint.dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | /usr/src/venv/bin/python3.8 manage.py migrate --noinput 3 | /usr/src/venv/bin/python3.8 manage.py collectstatic --noinput 4 | npx webpack --mode=development --watch & 5 | /usr/src/venv/bin/python3.8 manage.py runserver 0.0.0.0:8000 -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /invertimo/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "invertimo.settings") 7 | 8 | app = Celery("invertimo") 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | app.autodiscover_tasks() -------------------------------------------------------------------------------- /assets/select_positions.css: -------------------------------------------------------------------------------- 1 | .select-positions-li { 2 | display: flex; 3 | padding: 10px; 4 | margin-right: 5px; 5 | margin-bottom: 5px; 6 | border-radius: 5px; 7 | border: 1px solid #ccc; 8 | } 9 | 10 | .display-flex { 11 | display: flex; 12 | flex-wrap: wrap; 13 | } -------------------------------------------------------------------------------- /static/landing.css: -------------------------------------------------------------------------------- 1 | .main { 2 | text-align: center; 3 | max-width: 1300px; 4 | } 5 | 6 | .grid-container { 7 | grid-template-rows: 120px 1fr 120px; 8 | } 9 | 10 | @media only screen and (min-width: 350px) { 11 | .grid-container { 12 | grid-template-rows: 100px 1fr 120px; 13 | } 14 | } -------------------------------------------------------------------------------- /assets/routing.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | useLocation 4 | } from "react-router-dom"; 5 | 6 | // A custom hook that builds on useLocation to parse 7 | // the query string for you. 8 | export function useQuery() { 9 | const { search } = useLocation(); 10 | 11 | return React.useMemo(() => new URLSearchParams(search), [search]); 12 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | invertimoenv 2 | venv2 3 | node_modules/ 4 | data/ 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | pip-wheel-metadata/ 21 | share/python-wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | *index-bundle* -------------------------------------------------------------------------------- /assets/components/IconWithText.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import Icon from '@mui/material/Icon'; 5 | 6 | export default function IconWithText({icon, text}) { 7 | return <>{icon}{text}; 8 | } 9 | 10 | IconWithText.propTypes = { 11 | icon: PropTypes.string.isRequired, 12 | text: PropTypes.string.isRequired, 13 | }; -------------------------------------------------------------------------------- /assets/components/position_link.css: -------------------------------------------------------------------------------- 1 | .position-name { 2 | display: flex; 3 | flex-direction: column; 4 | flex-basis: 250px; 5 | } 6 | 7 | .position-name * { 8 | text-align: left; 9 | } 10 | .card-label { 11 | text-transform: uppercase; 12 | font-weight: bold; 13 | color: #1a777d; 14 | font-size: 0.7em; 15 | margin-bottom: 0.5em; 16 | } 17 | 18 | .position-symbol { 19 | font-weight: bold; 20 | font-size: 1.5em; 21 | } -------------------------------------------------------------------------------- /invertimo/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for invertimo project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'invertimo.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /invertimo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for invertimo project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'invertimo.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /deployment/invertimo.com_docker_compose.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Invertimo prod service with docker compose 3 | Requires=docker.service 4 | After=docker.service 5 | 6 | [Service] 7 | Restart=on-failure 8 | User=att 9 | WorkingDirectory=/home/att/sites/invertimo.com 10 | ExecStart=/usr/local/bin/docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --remove-orphans 11 | ExecStop=/usr/local/bin/docker-compose down 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /deployment/staging.invertimo.com_docker_compose.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Invertimo staging service with docker compose 3 | Requires=docker.service 4 | After=docker.service 5 | 6 | [Service] 7 | Restart=on-failure 8 | User=att 9 | WorkingDirectory=/home/att/sites/staging.invertimo.com 10 | ExecStart=/usr/local/bin/docker-compose -f docker-compose.yml -f docker-compose.staging.yml up --remove-orphans 11 | ExecStop=/usr/local/bin/docker-compose down 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /finance/migrations/0024_alter_lot_sell_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-10-28 19:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0023_auto_20211028_1835'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='lot', 15 | name='sell_date', 16 | field=models.DateField(null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0017_alter_asset_tracked.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-22 10:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0016_auto_20210822_1041'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='asset', 15 | name='tracked', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/binance_transaction_sample_with_income_mini.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,DOT,2.53024468, 3 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,EUR,-80, 4 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,EUR,-115, 5 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,ETH,0.03015547, 6 | 139221274,2022-01-04 00:50:06,Spot,POS savings interest,DOT,0.01416702, 7 | 139221274,2021-10-14 09:35:57,Spot,Savings Interest,DOT,0.00061872, -------------------------------------------------------------------------------- /finance/migrations/0014_auto_20210815_1653.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-15 16:53 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0013_alter_transaction_order_id'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel('Security', 'Asset'), 14 | migrations.RenameField('PriceHistory', 'security', 'asset'), 15 | migrations.RenameField('Position', 'security', 'asset'), 16 | ] 17 | -------------------------------------------------------------------------------- /deployment/setup_remote.sh: -------------------------------------------------------------------------------- 1 | scp ./server_setup.sh root@${HOSTNAME?}:~/ 2 | ssh root@${HOSTNAME?} "./server_setup.sh" 3 | 4 | scp ${HOSTNAME?}_docker_compose.service root@${HOSTNAME?}:/etc/systemd/system/${HOSTNAME?}_docker_compose.service 5 | scp ${HOSTNAME?}.nginx.conf root@${HOSTNAME?}:/etc/nginx/sites-enabled/${HOSTNAME?}.nginx.conf 6 | ssh root@${HOSTNAME?} "systemctl daemon-reload" 7 | ssh root@${HOSTNAME?} "systemctl enable ${HOSTNAME?}_docker_compose.service" 8 | ssh root@${HOSTNAME?} "systemctl enable ${HOSTNAME?}_docker_compose.service" -------------------------------------------------------------------------------- /finance/migrations/0007_alter_account_balance.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 13:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0006_auto_20210503_1358'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='account', 15 | name='balance', 16 | field=models.DecimalField(decimal_places=5, default=0, max_digits=12), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0008_alter_account_last_modified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 14:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0007_alter_account_balance'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='account', 15 | name='last_modified', 16 | field=models.DateTimeField(auto_now=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0030_alter_transaction_quantity.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-22 13:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0029_auto_20220121_1709'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='transaction', 15 | name='quantity', 16 | field=models.DecimalField(decimal_places=10, max_digits=20), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0013_alter_transaction_order_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-14 20:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0012_auto_20210725_1745'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='transaction', 15 | name='order_id', 16 | field=models.CharField(blank=True, max_length=200, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0005_position_quantity.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 12:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0004_alter_transaction_transaction_costs'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='position', 15 | name='quantity', 16 | field=models.DecimalField(decimal_places=2, default=0, max_digits=10), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0022_accountevent_withheld_taxes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-10-23 11:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0021_auto_20210929_1246'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='accountevent', 15 | name='withheld_taxes', 16 | field=models.DecimalField(decimal_places=6, default=0, max_digits=18), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | django==3.2 2 | django-debug-toolbar==3.2.1 3 | django-extensions==3.1.2 4 | djangorestframework==3.12.4 5 | jupyter==1.0.0 6 | numpy==1.20.2 7 | pandas==1.2.4 8 | requests==2.25.1 9 | gunicorn==20.1.0 10 | hypothesis==6.14.7 11 | ipdb==0.13.8 12 | mypy==0.812 13 | social-auth-app-django==4.0.0 14 | social-auth-core==4.1.0 15 | pip-tools==6.4.0 16 | psycopg2==2.8.6 17 | django-cors-headers==3.10.0 18 | django-stubs==1.8.0 19 | django-stubs-ext==0.2.0 20 | djangorestframework-stubs==1.4.0 21 | celery==5.2.3 22 | redis==4.1.4 23 | sentry-sdk==1.5.8 -------------------------------------------------------------------------------- /finance/migrations/0004_alter_transaction_transaction_costs.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-27 15:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0003_auto_20210427_1419'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='transaction', 15 | name='transaction_costs', 16 | field=models.DecimalField(decimal_places=5, max_digits=12, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0028_alter_transactionimport_integration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-16 19:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0027_transactionimport_account'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='transactionimport', 15 | name='integration', 16 | field=models.IntegerField(choices=[(1, 'DEGIRO'), (2, 'BINANCE_CSV')]), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /finance/migrations/0010_currencyexchangerate_value.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 15:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0009_currencyexchangerate_pricehistory'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='currencyexchangerate', 15 | name='value', 16 | field=models.DecimalField(decimal_places=5, default=None, max_digits=12), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /finance/migrations/0015_alter_asset_exchange.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-15 16:56 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0014_auto_20210815_1653'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='asset', 16 | name='exchange', 17 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='finance.exchange'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /finance/migrations/0020_accountevent_event_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-09-27 12:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0019_auto_20210822_1952'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='accountevent', 15 | name='event_type', 16 | field=models.IntegerField(choices=[(1, 'DEPOSIT'), (2, 'WITHDRAWAL'), (3, 'DIVIDEND')], default=1), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /finance/tasks.py: -------------------------------------------------------------------------------- 1 | 2 | from invertimo.celery import app 3 | from celery.utils.log import get_task_logger 4 | from finance import prices, models 5 | from django.core.management import call_command 6 | 7 | 8 | logger = get_task_logger(__name__) 9 | 10 | 11 | @app.task() 12 | def collect_prices(asset_id): 13 | asset = models.Asset.objects.get(pk=asset_id) 14 | logger.info(f"Collecting prices for asset: {asset}.") 15 | values = prices.collect_prices(asset) 16 | logger.info(f"Collected {len(values)} of prices for asset: {asset}.") 17 | 18 | 19 | @app.task() 20 | def fetch_prices(): 21 | call_command("fetch_prices") -------------------------------------------------------------------------------- /finance/binance_transaction_only_usd.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,DOT,2.53024468, 3 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,USD,-80, 4 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,USD,-115, 5 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,ETH,0.03015547, 6 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,DOT,6, 7 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,USD,-130.64, 8 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,USD,-67, 9 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,ETH,0.0191104, -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | 7 | web: 8 | build: . 9 | ports: 10 | - "8000:8000" 11 | depends_on: 12 | - db 13 | - redis 14 | restart: always 15 | 16 | redis: 17 | image: redis:alpine 18 | 19 | celery: 20 | build: . 21 | command: /usr/src/venv/bin/celery -A invertimo worker -l info 22 | depends_on: 23 | - db 24 | - redis 25 | restart: always 26 | 27 | celery-beat: 28 | build: . 29 | command: /usr/src/venv/bin/celery -A invertimo beat -l info 30 | depends_on: 31 | - db 32 | - redis 33 | restart: always -------------------------------------------------------------------------------- /assets/components/ImportButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconWithText from './IconWithText'; 3 | import SplitButtonNav from './SplitButtonNav'; 4 | 5 | 6 | const importOptions = [ 7 | { label: , link: "/transactions/import/degiro" }, 8 | { label: , link: "/transactions/import/binance" }, 9 | { label: , link: "/transactions/imports/" }, 10 | ]; 11 | 12 | 13 | export default function ImportButton() { 14 | return ; 15 | } -------------------------------------------------------------------------------- /finance/migrations/0037_alter_eventimportrecord_transaction.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-02-02 11:03 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0036_auto_20220202_1101'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='eventimportrecord', 16 | name='transaction', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_records', to='finance.transaction'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /finance/migrations/0027_transactionimport_account.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-12-27 16:50 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0026_transactionimport_transactionimportrecord'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='transactionimport', 16 | name='account', 17 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='finance.account'), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | "jest/globals": true 7 | 8 | }, 9 | extends: [ 10 | 'plugin:react/recommended', 11 | 'eslint:recommended', 12 | "plugin:jest/recommended" 13 | ], 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 12, 19 | sourceType: 'module', 20 | }, 21 | plugins: [ 22 | 'react', 23 | ], 24 | rules: { 25 | "jest/prefer-expect-assertions": [ 26 | "warn", 27 | { "onlyFunctionsWithAsyncKeyword": true } 28 | ], 29 | "semi": [2, "always"], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /finance/migrations/0035_auto_20220130_1802.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-30 18:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0034_auto_20220130_1743'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='transactionimport', 15 | options={'ordering': ['-created_at']}, 16 | ), 17 | migrations.AlterField( 18 | model_name='account', 19 | name='balance', 20 | field=models.DecimalField(decimal_places=10, default=0, max_digits=17), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /assets/display_utils.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function trimTrailingDecimalZeroes(numberAsString) { 4 | // If the number doesn't have a decimal point, then trimming decimal zeroes would 5 | // instead trim meaningful zeroes. 6 | // We don't want to do that. 7 | if (!numberAsString.includes(".")) { 8 | return numberAsString; 9 | } 10 | let end = numberAsString.length - 1; 11 | while (end > 0) { 12 | if (numberAsString[end] === "0") { 13 | end -= 1; 14 | } else { 15 | break; 16 | } 17 | } 18 | if (numberAsString[end] === ".") { 19 | end -= 1; 20 | } 21 | return numberAsString.slice(0, end+1); 22 | } -------------------------------------------------------------------------------- /assets/display_utils.test.js: -------------------------------------------------------------------------------- 1 | import { trimTrailingDecimalZeroes } from './display_utils.js'; 2 | 3 | 4 | describe("Display utils", () => { 5 | 6 | it("trimming trailing zeroes works for correct inputs", () => { 7 | 8 | const inputToExpected = new Map(Object.entries({ 9 | "0.000000": "0", 10 | "123.3330000": "123.333", 11 | "12000": "12000", 12 | "-234.440": "-234.44", 13 | "1234": "1234", 14 | })); 15 | 16 | for (let [input, expected] of inputToExpected.entries()) { 17 | let got = trimTrailingDecimalZeroes(input); 18 | expect(got).toEqual(expected); 19 | } 20 | }); 21 | 22 | }); -------------------------------------------------------------------------------- /finance/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ( 4 | Account, 5 | AccountEvent, 6 | Exchange, 7 | ExchangeIdentifier, 8 | Position, 9 | Asset, 10 | Transaction, 11 | TransactionImport, 12 | TransactionImportRecord, 13 | EventImportRecord, 14 | ) 15 | 16 | admin.site.register(Account) 17 | admin.site.register(AccountEvent) 18 | admin.site.register(Exchange) 19 | admin.site.register(ExchangeIdentifier) 20 | admin.site.register(Position) 21 | admin.site.register(Asset) 22 | admin.site.register(Transaction) 23 | admin.site.register(TransactionImport) 24 | admin.site.register(TransactionImportRecord) 25 | admin.site.register(EventImportRecord) -------------------------------------------------------------------------------- /finance/migrations/0039_auto_20220226_1240.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-02-26 12:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0038_auto_20220203_1112'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='asset', 15 | name='country', 16 | field=models.CharField(blank=True, max_length=200, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='asset', 20 | name='isin', 21 | field=models.CharField(blank=True, max_length=30), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /assets/currencies.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const currencyValues = [ 4 | "EUR", "USD", "GBP", "GBX", "HKD", "SGD", "JPY", "CAD", "PLN" 5 | ]; 6 | 7 | export const accountCurrencyValues = [ 8 | "EUR", "USD", "GBP", "HKD", "SGD", "JPY", "CAD", "PLN" 9 | ]; 10 | 11 | export const CURRENCY_TO_SYMBOL = new Map( 12 | [ 13 | ["USD", "$"], 14 | ["EUR", "€"], 15 | ["GBP", "£"], 16 | ["GBX", "GBX"], 17 | ["HKD", "HK$"], 18 | ["SGD", "S$"], 19 | ["JPY", "¥"], 20 | ["CAD", "C$"], 21 | ["PLN", "zł"], 22 | ] 23 | ); 24 | 25 | 26 | export function toSymbol(currency) { 27 | return CURRENCY_TO_SYMBOL.has(currency) ? CURRENCY_TO_SYMBOL.get(currency) : currency; 28 | } -------------------------------------------------------------------------------- /finance/migrations/0025_auto_20211102_1819.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-11-02 18:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0024_alter_lot_sell_date'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='position', 15 | name='cost_basis', 16 | field=models.DecimalField(decimal_places=5, default=0, max_digits=12), 17 | ), 18 | migrations.AddField( 19 | model_name='position', 20 | name='realized_gain', 21 | field=models.DecimalField(decimal_places=5, default=0, max_digits=12), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /assets/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from "react-dom"; 3 | import App from './App'; 4 | import * as Sentry from "@sentry/react"; 5 | import { BrowserTracing } from "@sentry/tracing"; 6 | 7 | const SENTRY_DSN = "https://f1329c993cec4d80b89e4698b7c8c715@o432350.ingest.sentry.io/6234379"; 8 | 9 | if (process.env.PRODUCTION) { 10 | Sentry.init({ 11 | dsn: SENTRY_DSN, 12 | integrations: [new BrowserTracing()], 13 | 14 | // Set tracesSampleRate to 1.0 to capture 100% 15 | // of transactions for performance monitoring. 16 | // We recommend adjusting this value in production 17 | tracesSampleRate: 1.0, 18 | }); 19 | } 20 | 21 | 22 | ReactDOM.render( 23 | , 24 | document.getElementById('root') 25 | ); -------------------------------------------------------------------------------- /finance/migrations/0012_auto_20210725_1745.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-07-25 17:45 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('finance', '0011_auto_20210529_0945'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='account', 17 | name='description', 18 | field=models.TextField(blank=True), 19 | ), 20 | migrations.AlterUniqueTogether( 21 | name='account', 22 | unique_together={('user', 'nickname')}, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'invertimo.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /finance/migrations/0016_auto_20210822_1041.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-22 10:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0015_alter_asset_exchange'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='asset', 15 | name='asset_type', 16 | field=models.IntegerField(choices=[(1, 'Stock'), (2, 'Bond'), (3, 'Fund')], default=1), 17 | ), 18 | migrations.AddField( 19 | model_name='asset', 20 | name='tracked', 21 | field=models.BooleanField(default=True), 22 | preserve_default=False, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /finance/migrations/0019_auto_20210822_1952.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-22 19:52 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0018_auto_20210822_1117'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='asset', 16 | name='exchange', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='finance.exchange'), 18 | ), 19 | migrations.AlterUniqueTogether( 20 | name='asset', 21 | unique_together=set(), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /assets/components/RecordButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconWithText from './IconWithText'; 3 | import SplitButtonNav from './SplitButtonNav'; 4 | 5 | 6 | const recordOptions = [ 7 | { label: , link: "/transactions/record" }, 8 | { label: , link: "/events/record_dividend" }, 9 | { label: , link: "/events/record_transfer" }, 10 | { label: , link: "/events/record_crypto_income" }, 11 | 12 | ]; 13 | 14 | 15 | export default function RecordButton() { 16 | return ; 17 | } -------------------------------------------------------------------------------- /finance/migrations/0011_auto_20210529_0945.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-29 09:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0010_currencyexchangerate_value'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='currencyexchangerate', 15 | options={'ordering': ['-date']}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='pricehistory', 19 | options={'ordering': ['-date']}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name='transaction', 23 | options={'ordering': ['-executed_at']}, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # setup.cfg 2 | [mypy] 3 | # The mypy configurations: https://mypy.readthedocs.io/en/latest/config_file.html 4 | python_version = 3.8 5 | 6 | check_untyped_defs = True 7 | disallow_any_generics = True 8 | disallow_untyped_calls = True 9 | disallow_untyped_decorators = True 10 | ignore_errors = False 11 | ignore_missing_imports = True 12 | implicit_reexport = False 13 | strict_optional = True 14 | strict_equality = True 15 | no_implicit_optional = True 16 | warn_unused_ignores = True 17 | warn_redundant_casts = True 18 | warn_unused_configs = True 19 | warn_unreachable = True 20 | warn_no_return = True 21 | plugins = 22 | mypy_django_plugin.main, 23 | mypy_drf_plugin.main 24 | 25 | [mypy.plugins.django-stubs] 26 | django_settings_module = invertimo.settings 27 | -------------------------------------------------------------------------------- /assets/transaction_list.css: -------------------------------------------------------------------------------- 1 | .position-card .asset-name { 2 | display: flex; 3 | flex-direction: column; 4 | flex-basis: 400px; 5 | } 6 | 7 | .position-symbol { 8 | font-weight: bold; 9 | font-size: 1.5em; 10 | } 11 | 12 | .trade-type { 13 | font-weight: 1000; 14 | font-size: 1.2em; 15 | } 16 | 17 | .trade-type-buy { 18 | color: #048349; 19 | } 20 | 21 | .trade-type-sell { 22 | color: #ce4036; 23 | } 24 | 25 | .position-card { 26 | padding: 0.3em; 27 | border-radius: 1px; 28 | border: 1px solid #384a5052; 29 | border-left: 5px solid #384a5052; 30 | display: flex; 31 | flex-direction: row; 32 | justify-content: space-between; 33 | padding-left: 10px; 34 | padding-right: 10px; 35 | align-items: center; 36 | } -------------------------------------------------------------------------------- /finance/migrations/0003_auto_20210427_1419.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-27 14:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0002_auto_20210426_1145'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='account', 15 | name='currency', 16 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX')], default=1), 17 | ), 18 | migrations.AlterField( 19 | model_name='security', 20 | name='currency', 21 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX')], default=3), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /finance/migrations/0029_auto_20220121_1709.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-21 17:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0028_alter_transactionimport_integration'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='asset', 15 | name='asset_type', 16 | field=models.IntegerField(choices=[(1, 'Stock'), (2, 'Bond'), (3, 'Fund'), (4, 'Crypto')], default=1), 17 | ), 18 | migrations.AlterField( 19 | model_name='asset', 20 | name='currency', 21 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX')], null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | volumes: 7 | - ./data/postgres_prod:/var/lib/postgresql/data 8 | env_file: 9 | - deployment/secrets/postgres.prod.env 10 | 11 | redis: 12 | volumes: 13 | - ./data/redis_prod:/data 14 | 15 | web: 16 | env_file: 17 | - deployment/secrets/invertimo.prod.env 18 | volumes: 19 | - /var/www/invertimo.com/static:/var/www/invertimo.com/static/ 20 | ports: 21 | - "8000:8000" 22 | depends_on: 23 | - db 24 | 25 | celery: 26 | env_file: 27 | - deployment/secrets/invertimo.prod.env 28 | volumes: 29 | - .:/usr/src/app 30 | celery-beat: 31 | env_file: 32 | - deployment/secrets/invertimo.prod.env 33 | volumes: 34 | - .:/usr/src/app -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | volumes: 7 | - ./data/postgres:/var/lib/postgresql/data 8 | env_file: 9 | - deployment/secrets/postgres.dev.env 10 | 11 | redis: 12 | volumes: 13 | - ./data/redis:/data 14 | 15 | web: 16 | build: . 17 | command: /usr/src/app/deployment/app/docker_entrypoint.dev.sh 18 | env_file: 19 | - deployment/secrets/invertimo.dev.env 20 | volumes: 21 | - .:/usr/src/app 22 | ports: 23 | - "8000:8000" 24 | depends_on: 25 | - db 26 | celery: 27 | env_file: 28 | - deployment/secrets/invertimo.dev.env 29 | volumes: 30 | - .:/usr/src/app 31 | celery-beat: 32 | env_file: 33 | - deployment/secrets/invertimo.dev.env 34 | volumes: 35 | - .:/usr/src/app 36 | -------------------------------------------------------------------------------- /docker-compose.staging.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | db: 5 | image: postgres 6 | volumes: 7 | - ./data/postgres_staging:/var/lib/postgresql/data 8 | env_file: 9 | - deployment/secrets/postgres.staging.env 10 | 11 | redis: 12 | volumes: 13 | - ./data/redis_staging:/data 14 | 15 | web: 16 | env_file: 17 | - deployment/secrets/invertimo.staging.env 18 | volumes: 19 | - /var/www/staging.invertimo.com/static:/var/www/staging.invertimo.com/static/ 20 | ports: 21 | - "8000:8000" 22 | depends_on: 23 | - db 24 | 25 | celery: 26 | env_file: 27 | - deployment/secrets/invertimo.staging.env 28 | volumes: 29 | - .:/usr/src/app 30 | celery-beat: 31 | env_file: 32 | - deployment/secrets/invertimo.staging.env 33 | volumes: 34 | - .:/usr/src/app -------------------------------------------------------------------------------- /finance/migrations/0021_auto_20210929_1246.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-09-29 12:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0020_accountevent_event_type'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='account', 15 | options={'ordering': ['-id']}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='accountevent', 19 | options={'ordering': ['-executed_at']}, 20 | ), 21 | migrations.AddField( 22 | model_name='accountevent', 23 | name='amount', 24 | field=models.DecimalField(decimal_places=6, default=0, max_digits=18), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /assets/forms/utils.test.js: -------------------------------------------------------------------------------- 1 | import { to2DecimalPlacesOr4SignificantDigits } from './utils.js'; 2 | 3 | 4 | describe("Utils", () => { 5 | 6 | it("numbers display reasonable number of digits", () => { 7 | 8 | const inputToExpected = new Map(Object.entries({ 9 | "0.0000001": "0.0000001", 10 | // Very small numbers still get a good number of digits displayed. 11 | "0.0000001234": "0.0000001234", 12 | // Big numbers are rounded to 2 decimal places. 13 | "123.3330000": "123.33", 14 | "12000": "12000", 15 | "-234.440": "-234.44", 16 | "1234": "1234", 17 | })); 18 | 19 | for (let [input, expected] of inputToExpected.entries()) { 20 | let got = to2DecimalPlacesOr4SignificantDigits(input); 21 | expect(got).toBe(expected); 22 | } 23 | }); 24 | 25 | }); -------------------------------------------------------------------------------- /finance/migrations/0002_auto_20210426_1145.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-26 11:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='exchange', 15 | name='country', 16 | field=models.CharField(default='', max_length=200), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='security', 21 | name='name', 22 | field=models.CharField(default='', max_length=200), 23 | preserve_default=False, 24 | ), 25 | migrations.AlterUniqueTogether( 26 | name='security', 27 | unique_together={('isin', 'exchange')}, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /finance/migrations/0018_auto_20210822_1117.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-08-22 11:17 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('finance', '0017_alter_asset_tracked'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='asset', 18 | name='added_by', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_assets', to=settings.AUTH_USER_MODEL), 20 | ), 21 | migrations.AlterField( 22 | model_name='asset', 23 | name='tracked', 24 | field=models.BooleanField(default=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /finance/migrations/0032_auto_20220129_1631.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-29 16:31 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0031_auto_20220122_1352'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='accountevent', 16 | name='transaction', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='finance.transaction'), 18 | ), 19 | migrations.AlterField( 20 | model_name='accountevent', 21 | name='event_type', 22 | field=models.IntegerField(choices=[(1, 'DEPOSIT'), (2, 'WITHDRAWAL'), (3, 'DIVIDEND'), (4, 'SAVINGS_INTEREST'), (5, 'STAKING_INTEREST')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /finance/migrations/0036_auto_20220202_1101.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-02-02 11:01 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0035_auto_20220130_1802'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='transactionimportrecord', 16 | name='transaction', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='records', to='finance.transaction'), 18 | ), 19 | migrations.AlterField( 20 | model_name='transactionimportrecord', 21 | name='transaction_import', 22 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_records', to='finance.transactionimport'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block more_head %} 5 | 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 | 29 | 30 | 31 | 32 | {% endblock %} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Run from the root project directory: 2 | # docker build -t invertimo:v0 -f deployment/app/Dockerfile . 3 | # To run on localhost (expects postgres running): 4 | # docker run -d --net=host invertimo:v0 5 | FROM ubuntu:20.04 6 | 7 | RUN apt-get update 8 | RUN apt-get -y install pip tmux htop python3.8-venv curl 9 | 10 | # Libpq is necessary for python PostgreSQL drivers. 11 | RUN apt-get install -y libpq-dev 12 | 13 | WORKDIR /usr/src/app 14 | 15 | COPY requirements.txt ./ 16 | RUN python3.8 -m venv /usr/src/venv 17 | RUN ls . 18 | RUN /usr/src/venv/bin/pip3.8 install --no-cache-dir -r requirements.txt 19 | 20 | RUN echo "Installing nodejs" 21 | RUN curl -sL https://deb.nodesource.com/setup_17.x | bash - 22 | RUN apt-get install -y nodejs 23 | COPY package.json . 24 | COPY package-lock.json . 25 | RUN npm install 26 | 27 | COPY . . 28 | ENV NODE_OPTIONS=--openssl-legacy-provider 29 | RUN npm run build 30 | CMD [ "./deployment/app/docker_entrypoint.sh" ] -------------------------------------------------------------------------------- /assets/components/Snackbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import MuiSnackbar from '@mui/material/Snackbar'; 4 | import MuiAlert from '@mui/material/Alert'; 5 | import PropTypes from 'prop-types'; 6 | 7 | 8 | export function Snackbar({ snackbarOpen, snackbarHandleClose, message, ...props }) { 9 | 10 | return 16 | 19 | {message} 20 | 21 | ; 22 | } 23 | 24 | Snackbar.propTypes = { 25 | snackbarOpen: PropTypes.bool.isRequired, 26 | snackbarHandleClose: PropTypes.func.isRequired, 27 | message: PropTypes.string.isRequired, 28 | severity: PropTypes.string, 29 | }; -------------------------------------------------------------------------------- /invertimo/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import logout 2 | from django.shortcuts import redirect, render 3 | from django.contrib.auth.decorators import login_required 4 | from django.http import HttpResponse, HttpRequest 5 | from typing import Dict, Any 6 | 7 | 8 | def index_view(request: HttpRequest) -> HttpResponse: 9 | if not request.user.is_authenticated: 10 | return render(request, "landing.html", {}) 11 | return render(request, "index.html", {}) 12 | 13 | 14 | def login_view(request: HttpRequest): 15 | context : Dict[str, Any] = {} 16 | return render(request, "login.html", context) 17 | 18 | def signup_view(request: HttpRequest): 19 | context : Dict[str, Any] = {} 20 | return render(request, "signup.html", context) 21 | 22 | def privacy_policy_view(request: HttpRequest)-> HttpResponse: 23 | context : Dict[str, Any] = {} 24 | return render(request, "privacy_policy.html", context) 25 | 26 | 27 | def logout_view(request: HttpRequest)-> HttpResponse: 28 | logout(request) 29 | return redirect("/") 30 | -------------------------------------------------------------------------------- /templates/signup.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block more_head %} 5 | 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 | 31 | 32 | 33 | 34 | {% endblock %} -------------------------------------------------------------------------------- /assets/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Portfolio from './Portfolio.js'; 3 | 4 | import { 5 | QueryClient, 6 | QueryClientProvider, 7 | } from 'react-query'; 8 | 9 | import { MyThemeProvider } from './theme.js'; 10 | import { 11 | HashRouter as Router, 12 | } from "react-router-dom"; 13 | 14 | 15 | import { useEffect } from "react"; 16 | import { useLocation } from "react-router-dom"; 17 | 18 | function ScrollToTop() { 19 | const { pathname } = useLocation(); 20 | 21 | useEffect(() => { 22 | window.scrollTo(0, 0); 23 | }, [pathname]); 24 | 25 | return null; 26 | } 27 | 28 | const queryClient = new QueryClient(); 29 | 30 | 31 | export default class App extends React.Component { 32 | render() { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | } -------------------------------------------------------------------------------- /finance/transactions_example_latest_bad_columns.csv: -------------------------------------------------------------------------------- 1 | Foo,Time,Product,Bar,Reference,Venue,Quantity,Price,,Local value,,Value,,Exchange rate,Transaction and/or third,,Total,,Order ID 2 | foo,15:30,PAYPAL HOLDINGS INC.,US70450Y103812,NDQ,XNAS,1,206.2600,USD,-206.26,USD,-181.90,EUR,1.1328,-0.50,EUR,-182.40,EUR,c5f82c79-b966-412a-bdbe-08363e125b4e 3 | foo,15:30,Nike,US6541061031,NDQ,XNAS,8,148.9900,USD,-1191.92,USD,-1012.68,EUR,1.1758,-0.53,EUR,-1013.21,EUR,83ddfa4b-1787-4f32-a13c-e30af6b907a6 4 | bar,15:30,META PLATFORMS INC,US30303M102712,NDQ,XNAS,5,358.7300,USD,-1793.65,USD,-1528.72,EUR,1.1721,-0.52,EUR,-1529.24,EUR,c13576de-c79f-4a3d-a988-889801300ba0 5 | 12-08-2021,15:30,COCA-COLA COMPANY (THE,US191216100712,NSY,XNYS,20,56.7300,USD,-1134.60,USD,-967.02,EUR,1.1721,-0.57,EUR,-967.59,EUR,cbfebf9d-6a2d-42ed-9a01-8a457bd537a4 6 | 12-08-2021,15:30,VISA INC.,US92826C839412,NSY,XNYS,5,233.8900,USD,-1169.45,USD,-996.72,EUR,1.1721,-0.52,EUR,-997.24,EUR,239a5ee6-463d-40a7-8b1e-838e27a2064c 7 | 12-08-2021,14:07,ISHARES MSCI WORLD SMALL CAP UCITS ETF USD ACC,IE00BF4RFH3112,XET,XETA,720,6.2940,EUR,-4531.68,EUR,-4531.68,EUR,,,,-4531.68,EUR,ee42096a-09e9-418f-902f-59f0e6a69dfb -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Justyna Ilczuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /finance/transactions_example_latest.csv: -------------------------------------------------------------------------------- 1 | Date,Time,Product,ISIN,Reference,Venue,Quantity,Price,,Local value,,Value,,Exchange rate,Transaction and/or third,,Total,,Order ID 2 | 18-11-2021,15:30,PAYPAL HOLDINGS INC.,US70450Y103812,NDQ,XNAS,1,206.2600,USD,-206.26,USD,-181.90,EUR,1.1328,-0.50,EUR,-182.40,EUR,c5f82c79-b966-412a-bdbe-08363e125b4e 3 | 13-08-2021,15:30,Nike,US6541061031,NDQ,XNAS,8,148.9900,USD,-1191.92,USD,-1012.68,EUR,1.1758,-0.53,EUR,-1013.21,EUR,83ddfa4b-1787-4f32-a13c-e30af6b907a6 4 | 12-08-2021,15:30,META PLATFORMS INC,US30303M102712,NDQ,XNAS,5,358.7300,USD,-1793.65,USD,-1528.72,EUR,1.1721,-0.52,EUR,-1529.24,EUR,c13576de-c79f-4a3d-a988-889801300ba0 5 | 12-08-2021,15:30,COCA-COLA COMPANY (THE,US191216100712,NSY,XNYS,20,56.7300,USD,-1134.60,USD,-967.02,EUR,1.1721,-0.57,EUR,-967.59,EUR,cbfebf9d-6a2d-42ed-9a01-8a457bd537a4 6 | 12-08-2021,15:30,VISA INC.,US92826C839412,NSY,XNYS,5,233.8900,USD,-1169.45,USD,-996.72,EUR,1.1721,-0.52,EUR,-997.24,EUR,239a5ee6-463d-40a7-8b1e-838e27a2064c 7 | 12-08-2021,14:07,ISHARES MSCI WORLD SMALL CAP UCITS ETF USD ACC,IE00BF4RFH3112,XET,XETA,720,6.2940,EUR,-4531.68,EUR,-4531.68,EUR,,,,-4531.68,EUR,ee42096a-09e9-418f-902f-59f0e6a69dfb -------------------------------------------------------------------------------- /finance/binance_transaction_sample_mismatched_dates.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-05-03 11:01:56,Spot,Deposit,EUR,98.20000000,"" 3 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,EUR,-20.00000000,"" 4 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,ADA,17.69000000,"" 5 | 139221274,2021-05-03 11:05:00,Spot,Transaction Related,EUR,-50.00000000,"" 6 | 139221274,2021-05-03 11:05:01,Spot,Transaction Related,BNB,0.09450000,"" 7 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,EUR,-20.00000000,"" 8 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,DOT,0.63800000,"" 9 | 139221274,2021-05-26 06:11:09,Spot,Deposit,EUR,200.00000000,"" 10 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,ETH,0.04366000,"" 11 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,EUR,-100.00000000,"" 12 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,EUR,-50.00000000,"" 13 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,ADA,34.70000000,"" 14 | 139221274,2021-05-26 16:29:15,Spot,Transaction Related,EUR,-55.00000000,"" 15 | 139221274,2021-05-26 16:19:15,Spot,Transaction Related,DOT,2.87000000,"" -------------------------------------------------------------------------------- /finance/transactions_example_latest_renamed.csv: -------------------------------------------------------------------------------- 1 | Data,Czas,Produkt,ISIN,Reference,Venue,Quantity,Price,,Local value,,Value,,Exchange rate,Transaction and/or third,,Total,,Order ID 2 | 18-11-2021,15:30,PAYPAL HOLDINGS INC.,US70450Y103812,NDQ,XNAS,1,206.2600,USD,-206.26,USD,-181.90,EUR,1.1328,-0.50,EUR,-182.40,EUR,c5f82c79-b966-412a-bdbe-08363e125b4e 3 | 13-08-2021,15:30,Nike,US6541061031,NDQ,XNAS,8,148.9900,USD,-1191.92,USD,-1012.68,EUR,1.1758,-0.53,EUR,-1013.21,EUR,83ddfa4b-1787-4f32-a13c-e30af6b907a6 4 | 12-08-2021,15:30,META PLATFORMS INC,US30303M102712,NDQ,XNAS,5,358.7300,USD,-1793.65,USD,-1528.72,EUR,1.1721,-0.52,EUR,-1529.24,EUR,c13576de-c79f-4a3d-a988-889801300ba0 5 | 12-08-2021,15:30,COCA-COLA COMPANY (THE,US191216100712,NSY,XNYS,20,56.7300,USD,-1134.60,USD,-967.02,EUR,1.1721,-0.57,EUR,-967.59,EUR,cbfebf9d-6a2d-42ed-9a01-8a457bd537a4 6 | 12-08-2021,15:30,VISA INC.,US92826C839412,NSY,XNYS,5,233.8900,USD,-1169.45,USD,-996.72,EUR,1.1721,-0.52,EUR,-997.24,EUR,239a5ee6-463d-40a7-8b1e-838e27a2064c 7 | 12-08-2021,14:07,ISHARES MSCI WORLD SMALL CAP UCITS ETF USD ACC,IE00BF4RFH3112,XET,XETA,720,6.2940,EUR,-4531.68,EUR,-4531.68,EUR,,,,-4531.68,EUR,ee42096a-09e9-418f-902f-59f0e6a69dfb -------------------------------------------------------------------------------- /assets/components/SubmitSpinnerButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '@mui/material/Button'; 4 | import CircularProgress from '@mui/material/CircularProgress'; 5 | 6 | import PropTypes from 'prop-types'; 7 | 8 | 9 | export default function SubmitSpinnerButton({isSubmitting, text}) { 10 | 11 | return ( 12 |
19 | {isSubmitting ? : null} 20 | 32 |
33 | ); 34 | } 35 | 36 | SubmitSpinnerButton.propTypes = { 37 | isSubmitting: PropTypes.bool.isRequired, 38 | text: PropTypes.string.isRequired, 39 | }; -------------------------------------------------------------------------------- /finance/management/commands/fetch_prices.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management.base import BaseCommand, CommandError 3 | from finance import accounts, prices, models 4 | from django.db.models import Count 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Fetch prices from eod historical data." 9 | 10 | def add_arguments(self, parser): 11 | pass 12 | 13 | def handle(self, *args, **options): 14 | assets = ( 15 | models.Asset.objects.filter(tracked=True) 16 | .annotate(positions_count=Count("positions")) 17 | .filter(positions_count__gte=1) 18 | ) 19 | self.stdout.write(f"Will fetch currency exchange rates") 20 | prices.collect_exchange_rates() 21 | self.stdout.write(self.style.SUCCESS(f"Collected exchange rates")) 22 | self.stdout.write(f"Will fetch prices for {assets.count()} securities") 23 | 24 | for asset in assets: 25 | price_records = prices.collect_prices(asset) 26 | self.stdout.write( 27 | self.style.SUCCESS(f"Collected {len(price_records)} prices for {asset}") 28 | ) 29 | -------------------------------------------------------------------------------- /assets/error_utils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export class ErrorBoundary extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { error: null, errorInfo: null }; 8 | } 9 | 10 | componentDidCatch(error, errorInfo) { 11 | // Catch errors in any components below and re-render with error message. 12 | this.setState({ 13 | error: error, 14 | errorInfo: errorInfo 15 | }); 16 | // You can also log error messages to an error reporting service here. 17 | } 18 | 19 | render() { 20 | if (this.state.errorInfo) { 21 | // Error path. 22 | return ( 23 |
24 | 25 |

Something went wrong.

26 | 27 |
28 | {this.state.error && this.state.error.toString()} 29 |
30 | {this.state.errorInfo.componentStack} 31 |
32 |
33 | ); 34 | } 35 | // Normally, just render children. 36 | return this.props.children; 37 | } 38 | } 39 | 40 | ErrorBoundary.propTypes = { 41 | children: PropTypes.any 42 | }; -------------------------------------------------------------------------------- /finance/transactions_example_short.csv: -------------------------------------------------------------------------------- 1 | Date,Time,Product,ISIN,Reference,Venue,Quantity,Price,,Local value,,Value,,Exchange rate,Transaction costs,,Total,,Order ID 2 | 07-04-2021,20:25,WALT DISNEY COMPANY (T,US2546871060,NSY,XNAS,16,188.1200,USD,-3009.92,USD,-2533.39,EUR,1.1869,-0.55,EUR,-2533.94,EUR,420be97a-265e-4b69-b7a7-acfad1ba8940 3 | 07-04-2021,20:23,APPLE INC. - COMMON ST,US0378331005,NDQ,CDED,18,127.6400,USD,-2297.52,USD,-1933.78,EUR,1.1869,-0.56,EUR,-1934.34,EUR,5e9d4dd0-6d3a-4ca2-aba5-356ac49e7ab4 4 | 07-04-2021,20:18,JOHNSON & JOHNSON COMM,US4781601046,NSY,CDED,12,163.4500,USD,-1961.40,USD,-1650.45,EUR,1.1872,-0.54,EUR,-1650.99,EUR,edfc6387-8ab8-438f-9b58-dfaa847b55db 5 | 07-04-2021,20:17,FEDEX CORPORATION COMM,US31428X1063,NSY,CDED,7,279.8200,USD,-1958.74,USD,-1648.35,EUR,1.1871,-0.52,EUR,-1648.87,EUR,9484dd5a-fcb2-464d-aed8-d5fe77ee1213 6 | 07-04-2021,20:16,COCA-COLA COMPANY (THE,US1912161007,NSY,SOHO,30,53.1500,USD,-1594.50,USD,-1341.83,EUR,1.1871,-0.60,EUR,-1342.43,EUR,e9f97484-b335-4a70-8109-23d0d0e793a7 7 | 01-04-2021,11:35,ISHARES MSCI WORLD SMALL CAP UCITS ETF USD ACC,IE00BF4RFH31,XET,XETA,1022,5.9930,EUR,-6124.85,EUR,-6124.85,EUR,,-1.84,EUR,-6126.69,EUR,c73c34a7-6397-45a3-aaa9-1c050100091e -------------------------------------------------------------------------------- /finance/migrations/0006_auto_20210503_1358.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 13:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0005_position_quantity'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='account', 15 | name='balance', 16 | field=models.DecimalField(decimal_places=5, default=0, max_digits=12), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='account', 21 | name='last_modified', 22 | field=models.DateTimeField(auto_now=True), 23 | ), 24 | migrations.AddField( 25 | model_name='position', 26 | name='last_modified', 27 | field=models.DateTimeField(auto_now=True), 28 | ), 29 | migrations.AddField( 30 | model_name='transaction', 31 | name='total_in_account_currency', 32 | field=models.DecimalField(decimal_places=5, default=0, max_digits=12), 33 | preserve_default=False, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /assets/SelectPositions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Icon from '@mui/material/Icon'; 4 | import './select_positions.css'; 5 | 6 | 7 | export function SelectPositions(props) { 8 | 9 | let positionItems = props.positions.map((position, i) => { 10 | return ( 11 |
  • 12 | # 15 | {position.asset.symbol} - {props.positionPercentages[i]}% (details north_east) 17 | 18 |
  • ); 19 | 20 | }); 21 | 22 | return ( 23 |
    24 |

    Top Positions (see all)

    25 |
      26 | {positionItems} 27 |
    28 |
    29 | ); 30 | } 31 | 32 | 33 | SelectPositions.propTypes = { 34 | positions: PropTypes.array.isRequired, 35 | colors: PropTypes.array.isRequired, 36 | positionPercentages: PropTypes.array.isRequired, 37 | }; -------------------------------------------------------------------------------- /finance/binance_transaction_sample_odd.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-05-03 11:01:56,Spot,Deposit,EUR,98.20000000,"" 3 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,EUR,-20.00000000,"" 4 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,ADA,17.69000000,"" 5 | 139221274,2021-05-03 11:05:00,Spot,Transaction Related,EUR,-50.00000000,"" 6 | 139221274,2021-05-03 11:05:01,Spot,Transaction Related,BNB,0.09450000,"" 7 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,EUR,-20.00000000,"" 8 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,DOT,0.63800000,"" 9 | 139221274,2021-05-26 06:11:09,Spot,Deposit,EUR,200.00000000,"" 10 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,ETH,0.04366000,"" 11 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,EUR,-100.00000000,"" 12 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,EUR,-50.00000000,"" 13 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,ADA,34.70000000,"" 14 | 139221274,2021-05-26 16:29:15,Spot,Transaction Related,EUR,-55.00000000,"" 15 | 139221274,2021-05-26 16:29:15,Spot,Transaction Related,DOT,2.87000000,"" 16 | 139221274,2021-06-24 08:21:01,Spot,Transaction Related,EUR,-100.00000000,"" -------------------------------------------------------------------------------- /finance/migrations/0034_auto_20220130_1743.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-30 17:43 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0033_eventimportrecord'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='eventimportrecord', 16 | name='event', 17 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_records', to='finance.accountevent'), 18 | ), 19 | migrations.AlterField( 20 | model_name='eventimportrecord', 21 | name='issue_type', 22 | field=models.IntegerField(choices=[(1, 'UNKNOWN_FAILURE'), (2, 'SOLD_BEFORE_BOUGHT'), (3, 'BAD_FORMAT'), (4, 'FAILED_TO_FETCH_PRICE')], null=True), 23 | ), 24 | migrations.AlterField( 25 | model_name='transactionimportrecord', 26 | name='issue_type', 27 | field=models.IntegerField(choices=[(1, 'UNKNOWN_FAILURE'), (2, 'SOLD_BEFORE_BOUGHT'), (3, 'BAD_FORMAT'), (4, 'FAILED_TO_FETCH_PRICE')], null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /assets/colors.js: -------------------------------------------------------------------------------- 1 | 2 | import * as d3 from 'd3-scale-chromatic'; 3 | 4 | 5 | 6 | function calculatePoint(i, intervalSize, colorRangeInfo) { 7 | let { colorStart, colorEnd, useEndAsStart } = colorRangeInfo; 8 | return (useEndAsStart 9 | ? (colorEnd - (i * intervalSize)) 10 | : (colorStart + (i * intervalSize))); 11 | } 12 | 13 | /* Must use an interpolated color scale, which has a range of [0, 1] */ 14 | function interpolateColors(dataLength, colorScale, colorRangeInfo) { 15 | let { colorStart, colorEnd } = colorRangeInfo; 16 | let colorRange = colorEnd - colorStart; 17 | let intervalSize = colorRange / dataLength; 18 | let i, colorPoint; 19 | let colorArray = []; 20 | 21 | for (i = 0; i < dataLength; i++) { 22 | colorPoint = calculatePoint(i, intervalSize, colorRangeInfo); 23 | colorArray.push(colorScale(colorPoint)); 24 | } 25 | 26 | return colorArray; 27 | } 28 | 29 | 30 | export function generateColors(count) { 31 | const colorRangeInfo = { 32 | colorStart: 0, 33 | colorEnd: 1, 34 | useEndAsStart: false, 35 | }; 36 | 37 | let colorScale = d3.interpolateRainbow; 38 | 39 | const dataLength = count; 40 | return interpolateColors(dataLength, colorScale, colorRangeInfo); 41 | } -------------------------------------------------------------------------------- /finance/binance_transaction_sample_dates_slight_offset.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-05-03 11:01:56,Spot,Deposit,EUR,98.20000000,"" 3 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,EUR,-20.00000000,"" 4 | 139221274,2021-05-03 11:03:22,Spot,Transaction Related,ADA,17.69000000,"" 5 | 139221274,2021-05-03 11:05:00,Spot,Transaction Related,EUR,-50.00000000,"" 6 | 139221274,2021-05-03 11:05:01,Spot,Transaction Related,BNB,0.09450000,"" 7 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,EUR,-20.00000000,"" 8 | 139221274,2021-05-03 11:06:26,Spot,Transaction Related,DOT,0.63800000,"" 9 | 139221274,2021-05-26 06:11:09,Spot,Deposit,EUR,200.00000000,"" 10 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,ETH,0.04366000,"" 11 | 139221274,2021-05-26 16:27:19,Spot,Transaction Related,EUR,-100.00000000,"" 12 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,EUR,-50.00000000,"" 13 | 139221274,2021-05-26 16:28:19,Spot,Transaction Related,ADA,34.70000000,"" 14 | 139221274,2021-05-26 16:29:15,Spot,Transaction Related,EUR,-55.00000000,"" 15 | 139221274,2021-05-26 16:29:15,Spot,Transaction Related,DOT,2.87000000,"" 16 | 139221274,2021-06-24 08:21:01,Spot,Transaction Related,EUR,-100.00000000,"" 17 | 139221274,2021-06-24 08:21:02,Spot,Transaction Related,ADA,88.15000000,"" -------------------------------------------------------------------------------- /assets/AccountValues.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const AccountValue = React.lazy(() => import('./AccountValue')); 6 | 7 | export default function AccountValues(props) { 8 | 9 | if (!props.accountValues) { 10 | return
    Still crunching the numbers...
    ; 11 | } 12 | let accountValues = props.accounts.filter(account => 13 | props.accountValues.get(account.id)).map((account) => { 14 | 15 | let accountDetail = props.accountValues.get(account.id); 16 | let values = []; 17 | if (accountDetail) { 18 | values = accountDetail.values; 19 | } 20 | return ( 21 | 23 | ); 24 | }); 25 | let maybeLoadingMore = null; 26 | if (props.accountValues.size !== props.accounts.length) { 27 | maybeLoadingMore =

    Still fetching data for remaining accounts...

    ; 28 | } 29 | return
    {accountValues} {maybeLoadingMore}
    ; 30 | } 31 | 32 | AccountValues.propTypes = { 33 | accountValues: PropTypes.object, 34 | accounts: PropTypes.array.isRequired, 35 | positions: PropTypes.array.isRequired, 36 | }; -------------------------------------------------------------------------------- /templates/base_internal.tmpl.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | Invertimo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% block more_head %} 19 | {% endblock %} 20 | 21 | 22 | 23 |
    24 |
    25 | 26 | {% block content %} 27 | {% endblock %} 28 | 29 |
    30 | 33 |
    34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /assets/components/EventTypeDisplay.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Icon from '@mui/material/Icon'; 4 | import makeStyles from '@mui/styles/makeStyles'; 5 | 6 | 7 | const useStyles = makeStyles({ 8 | eventType: { 9 | display: "flex", 10 | alignItems: "center", 11 | gap: "5px", 12 | } 13 | }); 14 | 15 | export function EventTypeDisplay({ eventType }) { 16 | const classes = useStyles(); 17 | 18 | if (eventType === "DEPOSIT") { 19 | return sync_altDeposit; 20 | } 21 | else if (eventType === "WITHDRAWAL") { 22 | return sync_altWithdrawal; 23 | } else if (eventType === "DIVIDEND") { 24 | return paidDividend; 25 | } else if (eventType === "SAVINGS_INTEREST") { 26 | return savingsSavings Interest; 27 | } else if (eventType === "STAKING_INTEREST") { 28 | return savingsStaking Interest; 29 | } 30 | else { 31 | return {eventType}; 32 | } 33 | } 34 | 35 | 36 | EventTypeDisplay.propTypes = { 37 | eventType: PropTypes.string.isRequired, 38 | }; -------------------------------------------------------------------------------- /finance/migrations/0031_auto_20220122_1352.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-22 13:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0030_alter_transaction_quantity'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='transaction', 15 | name='local_value', 16 | field=models.DecimalField(decimal_places=10, max_digits=19), 17 | ), 18 | migrations.AlterField( 19 | model_name='transaction', 20 | name='price', 21 | field=models.DecimalField(decimal_places=10, max_digits=18), 22 | ), 23 | migrations.AlterField( 24 | model_name='transaction', 25 | name='total_in_account_currency', 26 | field=models.DecimalField(decimal_places=10, max_digits=18), 27 | ), 28 | migrations.AlterField( 29 | model_name='transaction', 30 | name='transaction_costs', 31 | field=models.DecimalField(decimal_places=10, max_digits=18, null=True), 32 | ), 33 | migrations.AlterField( 34 | model_name='transaction', 35 | name='value_in_account_currency', 36 | field=models.DecimalField(decimal_places=10, max_digits=18), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /finance/management/commands/import_transactions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management.base import BaseCommand 3 | from finance import accounts 4 | from finance.integrations import degiro_parser 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Import degiro transactions from a file.' 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument('--filename', type=str, help="file to read transactions from") 12 | parser.add_argument('--username', type=str, help="username of the account owner") 13 | parser.add_argument('--account_id', type=str, help="account nickname") 14 | 15 | def handle(self, *args, **options): 16 | account_id = options['account_id'] 17 | username = options['username'] 18 | user = User.objects.get(username=username) 19 | account = accounts.AccountRepository().get(user, account_id) 20 | filename = options['filename'] 21 | failed_rows = degiro_parser.import_transactions_from_file(account, filename) 22 | 23 | self.stdout.write(self.style.SUCCESS('Finished the import')) 24 | if failed_rows: 25 | self.stderr.write( 26 | 'Failed rows:') 27 | for row in failed_rows: 28 | self.stderr.write( 29 | f"Failed to import ISIN: {row['ISIN']}, exchange: {row['Reference']} {row['Venue']}") 30 | -------------------------------------------------------------------------------- /finance/migrations/0009_currencyexchangerate_pricehistory.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-05-03 15:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0008_alter_account_last_modified'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='CurrencyExchangeRate', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('from_currency', models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX')])), 19 | ('to_currency', models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX')])), 20 | ('date', models.DateField()), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='PriceHistory', 25 | fields=[ 26 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('value', models.DecimalField(decimal_places=5, max_digits=12)), 28 | ('date', models.DateField()), 29 | ('security', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.security')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /finance/binance_transaction_sample.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,DOT,2.53024468, 3 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,EUR,-80, 4 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,EUR,-115, 5 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,ETH,0.03015547, 6 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,DOT,6, 7 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,EUR,-130.64, 8 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,EUR,-67, 9 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,ETH,0.0191104, 10 | 139221274,2022-01-05 20:53:02,Spot,Transaction Related,ETH,0.06242777, 11 | 139221274,2022-01-05 20:53:02,Spot,Transaction Related,EUR,-200, 12 | 139221274,2022-01-05 20:53:55,Spot,Transaction Related,EUR,-100, 13 | 139221274,2022-01-05 20:53:55,Spot,Transaction Related,DOT,4.14273184, 14 | 139221274,2022-01-10 22:08:48,Spot,Transaction Related,EUR,-200, 15 | 139221274,2022-01-10 22:08:48,Spot,Transaction Related,ETH,0.07337214, 16 | 139221274,2022-01-10 22:09:24,Spot,Transaction Related,DOT,4.77265957, 17 | 139221274,2022-01-10 22:09:24,Spot,Transaction Related,EUR,-100, 18 | 139221274,2021-11-29 09:54:43,Spot,Deposit,EUR,196.4, 19 | 139221274,2021-12-20 21:27:15,Spot,Deposit,EUR,196.4, 20 | 139221274,2022-01-05 20:50:13,Spot,Deposit,EUR,999, 21 | 139221274,2022-01-05 20:50:13,Spot,Withdrawal,EUR,-100, -------------------------------------------------------------------------------- /assets/components/DatePicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import isValid from 'date-fns/isValid'; 3 | import TextField from '@mui/material/TextField'; 4 | 5 | import PropTypes from 'prop-types'; 6 | 7 | import MuiDatePicker from '@mui/lab/DatePicker'; 8 | 9 | 10 | export default function DatePicker(props) { 11 | 12 | return ( } 24 | value={props.value} 25 | autoOk={true} 26 | disabled={props.disabled} 27 | onChange={props.onChange} 28 | KeyboardButtonProps={{ 29 | 'aria-label': props.ariaLabel ?? "", 30 | }} 31 | {...props} 32 | />); 33 | } 34 | 35 | 36 | DatePicker.propTypes = { 37 | value: PropTypes.any, 38 | disabled: PropTypes.bool.isRequired, 39 | onChange: PropTypes.func.isRequired, 40 | ariaLabel: PropTypes.string, 41 | toShowError: PropTypes.bool, 42 | currentError: PropTypes.string, 43 | helperText: PropTypes.string, 44 | onBlur: PropTypes.func, 45 | }; -------------------------------------------------------------------------------- /assets/forms/utils.js: -------------------------------------------------------------------------------- 1 | import { reduce } from 'lodash'; 2 | 3 | import Decimal from 'decimal.js'; 4 | 5 | const number2Regex = /^[+-]?\d*(\.\d{0,2})?$/; 6 | const number10Regex = /^[+-]?\d*(\.\d{0,10})?$/; 7 | 8 | 9 | export function matchNumberUpToTwoDecimalPlaces(value) { 10 | return value === undefined || (value + "").match(number2Regex); 11 | } 12 | 13 | 14 | export function matchNumberUpToTenDecimalPlaces(value) { 15 | return value === undefined || (value + "").match(number10Regex); 16 | } 17 | 18 | 19 | export function roundToTwoDecimalString(toRound) { 20 | const originalAmount = Number(toRound); 21 | const roundedAmount = Math.round(originalAmount * 100) / 100; 22 | return originalAmount === roundedAmount ? originalAmount : "~" + roundedAmount; 23 | } 24 | 25 | export function roundDecimal(num) { 26 | return num.mul(100).round().div(100).toString(); 27 | } 28 | 29 | export function sumAsDecimals(arrayOfStrings) { 30 | return reduce(arrayOfStrings, (sum, val) => sum.plus(new Decimal(val)), new Decimal('0')); 31 | } 32 | 33 | export function to2DecimalPlacesOr4SignificantDigits(number) { 34 | let value = new Decimal(number); 35 | if (value.abs().comparedTo(new Decimal(1)) < 0) { 36 | Decimal.set({ 37 | toExpNeg: -8, 38 | }); 39 | return value.toSignificantDigits(4).toString(); 40 | } else { 41 | value = value.mul(100).round().div(100); 42 | return value.toString(); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /finance/migrations/0040_auto_20220402_1814.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-04-02 18:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('finance', '0039_auto_20220226_1240'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='account', 15 | name='currency', 16 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX'), (5, 'HKD'), (6, 'SGD'), (7, 'JPY'), (8, 'CAD'), (9, 'PLN')], default=1), 17 | ), 18 | migrations.AlterField( 19 | model_name='asset', 20 | name='currency', 21 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX'), (5, 'HKD'), (6, 'SGD'), (7, 'JPY'), (8, 'CAD'), (9, 'PLN')], null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name='currencyexchangerate', 25 | name='from_currency', 26 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX'), (5, 'HKD'), (6, 'SGD'), (7, 'JPY'), (8, 'CAD'), (9, 'PLN')]), 27 | ), 28 | migrations.AlterField( 29 | model_name='currencyexchangerate', 30 | name='to_currency', 31 | field=models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD'), (4, 'GBX'), (5, 'HKD'), (6, 'SGD'), (7, 'JPY'), (8, 'CAD'), (9, 'PLN')]), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /finance/migrations/0033_eventimportrecord.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-01-30 17:31 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0032_auto_20220129_1631'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='EventImportRecord', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('raw_record', models.TextField()), 19 | ('created_new', models.BooleanField(default=False)), 20 | ('successful', models.BooleanField(default=True)), 21 | ('issue_type', models.IntegerField(choices=[(1, 'UNKNOWN_FAILURE'), (2, 'SOLD_BEFORE_BOUGHT'), (3, 'BAD_FORMAT')], null=True)), 22 | ('raw_issue', models.TextField(null=True)), 23 | ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_records', to='finance.transaction')), 24 | ('transaction', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='event_transaction_records', to='finance.transaction')), 25 | ('transaction_import', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_records', to='finance.transactionimport')), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /assets/assetOptions.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const assetTypeOptions = [ 4 | { 5 | value: "Stock", 6 | label: "Stock", 7 | }, 8 | { 9 | value: "Fund", 10 | label: "Fund", 11 | }, 12 | { 13 | value: "Crypto", 14 | label: "Crypto", 15 | }, 16 | ]; 17 | 18 | export const exchangeOptions = [ 19 | "USA Stocks", 20 | "XETRA Exchange", 21 | "London Exchange", 22 | "Borsa Italiana", 23 | "Euronext Paris", 24 | "Euronext Amsterdam", 25 | "Madrid Exchange", 26 | "Frankfurt Exchange", 27 | "Warsaw Stock Exchange", 28 | "Singapore Exchange", 29 | "Hong Kong Exchange", 30 | "Canadian Securities Exchange", 31 | "Other / NA"].map(name => ({ 32 | value: name, label: name, 33 | })); 34 | 35 | 36 | export const currencyOptions = [ 37 | { 38 | value: "USD", 39 | label: "$ USD", 40 | }, 41 | { 42 | value: "EUR", 43 | label: "€ EUR", 44 | }, 45 | { 46 | value: "GBP", 47 | label: "£ GBP", 48 | }, 49 | { 50 | value: "GBX", 51 | label: "GBX", 52 | }, 53 | { 54 | value: "HKD", 55 | label: "HK$ HKD", 56 | }, 57 | { 58 | value: "SGD", 59 | label: "S$ SGD", 60 | }, 61 | { 62 | value: "JPY", 63 | label: "¥ JPY", 64 | }, 65 | { 66 | value: "CAD", 67 | label: "C$ CAD", 68 | }, 69 | { 70 | value: "PLN", 71 | label: "zł PLN", 72 | }, 73 | ]; -------------------------------------------------------------------------------- /assets/TimeSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | 5 | 6 | export function daysFromDurationObject(duration) { 7 | if (duration == null) { 8 | return null; 9 | } 10 | let totalDays = 0; 11 | if (duration.days) { 12 | 13 | totalDays += duration.days; 14 | } 15 | if (duration.months) { 16 | totalDays += duration.months * 31; 17 | } 18 | if (duration.years) { 19 | totalDays += duration.years * 365; 20 | } 21 | return totalDays; 22 | } 23 | 24 | export class TimeSelector extends React.Component { 25 | 26 | render() { 27 | let items = [ 28 | { id: 1, content: "1 week", value: { days: 7 } }, 29 | { id: 2, content: "1 month", value: { months: 1 } }, 30 | { id: 3, content: "3 months", value: { months: 3 } }, 31 | { id: 4, content: "1 year", value: { years: 1 } }, 32 | { id: 5, content: "3 years", value: { years: 3 } }, 33 | { id: 6, content: "Max", value: null }, 34 | ]; 35 | 36 | let options = items.map(item => (
  • this.props.onClick(item.id, item.value)} 40 | > 41 | {item.content} 42 | 43 |
  • ) 44 | ); 45 | 46 | return ( 47 |
      48 | {options} 49 |
    50 | ); 51 | } 52 | } 53 | 54 | TimeSelector.propTypes = { 55 | activeId: PropTypes.any.isRequired, 56 | onClick: PropTypes.func.isRequired 57 | }; -------------------------------------------------------------------------------- /assets/components/PositionLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Badge from '@mui/material/Badge'; 4 | import PropTypes from 'prop-types'; 5 | import './position_link.css'; 6 | 7 | 8 | export function PositionLink({ position, account, style }) { 9 | return (
    10 | 11 | {position.asset.isin} 12 | 13 | 21 | {position.asset.symbol} 22 | 23 | {position.asset.symbol !== position.asset.name ? position.asset.name : null} 24 | 25 | ({account.nickname}) 26 |
    ); 27 | } 28 | 29 | 30 | PositionLink.propTypes = { 31 | position: PropTypes.shape({ 32 | id: PropTypes.number.isRequired, 33 | asset: PropTypes.shape( 34 | { 35 | isin: PropTypes.string.isRequired, 36 | symbol: PropTypes.string.isRequired, 37 | name: PropTypes.string.isRequired, 38 | tracked: PropTypes.bool.isRequired, 39 | asset_type: PropTypes.string.isRequired, 40 | }).isRequired, 41 | 42 | }).isRequired, 43 | account: PropTypes.shape({ 44 | id: PropTypes.number.isRequired, 45 | nickname: PropTypes.string.isRequired, 46 | }).isRequired, 47 | style: PropTypes.object, 48 | }; -------------------------------------------------------------------------------- /deployment/Readme.md: -------------------------------------------------------------------------------- 1 | # Building and running docker containers 2 | 3 | Run from the root project directory: 4 | 5 | ```shell 6 | docker build -t invertimo:v0 . 7 | ``` 8 | To run with docker compose: 9 | 10 | ``` 11 | docker-compose -f docker-compose.yml -f docker-compose.dev.yml up 12 | ``` 13 | 14 | ``` 15 | docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --force-recreate 16 | ``` 17 | 18 | This needs to be run to initialize db: 19 | 20 | ```shell 21 | docker exec invertimo_web_1 sh -c "/usr/src/venv/bin/python3.8 manage.py migrate" 22 | 23 | docker exec invertimo_web_1 sh -c "/usr/src/venv/bin/python3.8 manage.py loaddata finance/fixtures/exchanges.json" 24 | 25 | docker exec staginginvertimocom_web_1 sh -c "/usr/src/venv/bin/python3.8 manage.py import_transactions --username=justyna --account_id=1 --filename=finance/transactions_example.csv" 26 | 27 | docker exec staginginvertimocom_web_1 sh -c p 28 | ``` 29 | 30 | Run tests within container: 31 | 32 | ```shell 33 | docker exec -i invertimo_web_1 sh -c "/usr/src/venv/bin/python3.8 manage.py test" 34 | ``` 35 | 36 | ## Secrets (passwords, API keys, etc) 37 | 38 | Are stored in a private repository. 39 | 40 | It is included as a submodule: 41 | 42 | ``` 43 | git submodule add git@github.com:ilonajulczuk/invertimoenv.git secrets 44 | ``` 45 | 46 | To make changes, go into the directory `secrets`, edit files. Commit and push them. 47 | Then in the outer repository `git add` the changes (without the trailing slash): 48 | 49 | ``` 50 | git add deployment/secrets 51 | ``` 52 | 53 | and commit that :). 54 | 55 | To get submodules working after cloning the initial repo: 56 | 57 | ``` 58 | git submodule init 59 | git submodule update 60 | ``` -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | Invertimo 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block more_head %} 15 | {% endblock %} 16 | 17 | 18 | 19 |
    20 |
    21 |
    22 | 23 | 24 | 29 |
    30 | 31 |
    32 | 33 |
    34 | 35 | {% block content %} 36 | {% endblock %} 37 | 38 |
    39 | 42 |
    43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /assets/forms/styles.js: -------------------------------------------------------------------------------- 1 | import makeStyles from '@mui/styles/makeStyles'; 2 | import { green, red } from '@mui/material/colors'; 3 | 4 | export const useStyles = makeStyles((theme) => ({ 5 | form: { 6 | display: "flex", 7 | flexWrap: "wrap", 8 | flexDirection: "column", 9 | }, 10 | formWithMargins: { 11 | display: "flex", 12 | flexWrap: "wrap", 13 | flexDirection: "column", 14 | marginTop: "20px", 15 | marginBottom: "20px", 16 | }, 17 | formControl: { 18 | minWidth: 120, 19 | maxWidth: 300, 20 | }, 21 | narrowInput: { 22 | minWidth: 60, 23 | maxWidth: 100, 24 | }, 25 | mediumInput: { 26 | minWidth: "200px", 27 | }, 28 | wideInput: { 29 | minWidth: "300px", 30 | }, 31 | inputs: { 32 | marginTop: theme.spacing(1), 33 | marginBottom: theme.spacing(1), 34 | display: "flex", 35 | flexWrap: "wrap", 36 | gap: "10px", 37 | alignItems: "baseline", 38 | }, 39 | green: { 40 | color: green[600], 41 | '& *': { 42 | color: green[600], 43 | }, 44 | }, 45 | red: { 46 | color: red[600], 47 | '& *': { 48 | color: red[600], 49 | }, 50 | }, 51 | submitButton: { 52 | marginTop: "2em", 53 | marginBottom: "2em", 54 | }, 55 | bottomButtons: { 56 | marginTop: theme.spacing(4), 57 | justifyContent: "right", 58 | }, 59 | symbolInput: { 60 | minWidth: 320, 61 | maxWidth: 500, 62 | }, 63 | symbolOption: { 64 | display: "flex", 65 | flexDirection: "column", 66 | }, 67 | })); 68 | 69 | -------------------------------------------------------------------------------- /finance/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | from typing import Callable 4 | 5 | 6 | def generate_datetime_intervals( 7 | from_date: datetime.datetime, 8 | to_date: datetime.datetime, 9 | output_period: datetime.timedelta, 10 | start_with_end: bool = True, 11 | ) -> List[datetime.datetime]: 12 | dates = [] 13 | comparison : Callable[[datetime.datetime, datetime.datetime], bool] 14 | if start_with_end: 15 | start_date = to_date 16 | end_date = from_date 17 | delta = -output_period 18 | comparison = lambda x, y: x >= y 19 | else: 20 | start_date = from_date 21 | end_date = to_date 22 | delta = output_period 23 | comparison = lambda x, y: x <= y 24 | 25 | current_date = start_date 26 | while comparison(current_date, end_date): 27 | dates.append(current_date) 28 | current_date += delta 29 | return dates 30 | 31 | 32 | def generate_date_intervals( 33 | from_date: datetime.date, 34 | to_date: datetime.date, 35 | output_period: datetime.timedelta=datetime.timedelta(days=1), 36 | start_with_end: bool = True, 37 | ) -> List[datetime.date]: 38 | dates = [] 39 | comparison : Callable[[datetime.date, datetime.date], bool] 40 | if start_with_end: 41 | start_date = to_date 42 | end_date = from_date 43 | delta = -output_period 44 | comparison = lambda x, y: x >= y 45 | else: 46 | start_date = from_date 47 | end_date = to_date 48 | delta = output_period 49 | comparison = lambda x, y: x <= y 50 | 51 | current_date = start_date 52 | while comparison(current_date, end_date): 53 | dates.append(current_date) 54 | current_date += delta 55 | return dates -------------------------------------------------------------------------------- /assets/theme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import { ThemeProvider, StyledEngineProvider, createTheme, adaptV4Theme } from '@mui/material/styles'; 5 | 6 | 7 | export const themeOptions = { 8 | palette: { 9 | mode: 'light', 10 | primary: { 11 | veryLight: "rgba(27, 152, 161, 0.08)", 12 | light: "hsl(184deg 51% 48%)", 13 | main: '#1b98a1', 14 | dark: "hsl(184deg 61% 36%)", 15 | }, 16 | secondary: { 17 | main: 'hsl(4deg 61% 51%)', 18 | dark: 'hsl(4deg 70% 42%)', 19 | light: 'hsl(4deg 71% 60% / 83%)', 20 | veryLight: '#fcd7d585', 21 | contrastText: '#fff', 22 | }, 23 | }, 24 | typography: { 25 | fontFamily: 'Open Sans', 26 | h1: { 27 | fontFamily: 'comfortaa', 28 | }, 29 | h2: { 30 | fontFamily: 'comfortaa', 31 | }, 32 | h3: { 33 | fontFamily: 'comfortaa', 34 | }, 35 | h4: { 36 | fontFamily: 'comfortaa', 37 | }, 38 | h5: { 39 | fontFamily: 'comfortaa', 40 | }, 41 | button: { 42 | textTransform: 'none', 43 | fontWeight: 'bold', 44 | } 45 | }, 46 | shape: { 47 | borderRadius: 0, 48 | }, 49 | }; 50 | 51 | export function MyThemeProvider(props) { 52 | return ( 53 | 54 | 55 | {props.children} 56 | 57 | 58 | ); 59 | } 60 | 61 | MyThemeProvider.propTypes = { 62 | children: PropTypes.any, 63 | }; -------------------------------------------------------------------------------- /templates/privacy_policy.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block content %} 5 | 6 |
    7 | 8 |
    9 |

    Privacy Policy

    10 |

    11 | This privacy policy will explain how Invertimo (this website) uses the personal data we collect from you 12 | when 13 | you use our website. 14 |

    15 |

    16 | Data that you provide on the website is encrypted in transit (HTTPS) and at rest (stored in database that 17 | encrypts it). 18 |

    19 |

    20 | The data that you provide through various forms is not used for any other purposes than the Invertimo 21 | services that are provided to you. 22 | Some of your data, e.g. if you use certain feature might be used for decisions to improve the app itself. 23 |

    24 |

    25 | We might contact you about your experience with the app by using the email you used in the application. 26 |

    27 |

    28 | We use error tracking software called sentry. If you encounter an error, 29 | the problematic request will be securely stored in sentry.io Invertimo project and available for later 30 | inspection so that we can prevent errors like this from happening in the future. 31 |

    32 |

    33 | If you delete your account, all associated data will be deleted. When you delete things from the 34 | application, they are deleted. 35 | We store about 1 month of backup data, but don't perform regular backups. 36 |

    37 |

    If you have more questions please contact justyna@invertimo.com

    38 |
    39 |
    40 | 41 | {% endblock %} -------------------------------------------------------------------------------- /finance/binance_transaction_sample_with_income.csv: -------------------------------------------------------------------------------- 1 | User_ID,UTC_Time,Account,Operation,Coin,Change,Remark 2 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,DOT,2.53024468, 3 | 139221274,2021-11-29 09:56:38,Spot,Transaction Related,EUR,-80, 4 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,EUR,-115, 5 | 139221274,2021-11-29 09:57:28,Spot,Transaction Related,ETH,0.03015547, 6 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,DOT,6, 7 | 139221274,2021-12-20 21:29:02,Spot,Transaction Related,EUR,-130.64, 8 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,EUR,-67, 9 | 139221274,2021-12-20 21:29:42,Spot,Transaction Related,ETH,0.0191104, 10 | 139221274,2022-01-05 20:53:02,Spot,Transaction Related,ETH,0.06242777, 11 | 139221274,2022-01-05 20:53:02,Spot,Transaction Related,EUR,-200, 12 | 139221274,2022-01-05 20:53:55,Spot,Transaction Related,EUR,-100, 13 | 139221274,2022-01-05 20:53:55,Spot,Transaction Related,DOT,4.14273184, 14 | 139221274,2022-01-10 22:08:48,Spot,Transaction Related,EUR,-200, 15 | 139221274,2022-01-10 22:08:48,Spot,Transaction Related,ETH,0.07337214, 16 | 139221274,2022-01-10 22:09:24,Spot,Transaction Related,DOT,4.77265957, 17 | 139221274,2022-01-10 22:09:24,Spot,Transaction Related,EUR,-100, 18 | 139221274,2021-11-29 09:54:43,Spot,Deposit,EUR,196.4, 19 | 139221274,2021-12-20 21:27:15,Spot,Deposit,EUR,196.4, 20 | 139221274,2022-01-05 20:50:13,Spot,Deposit,EUR,999, 21 | 139221274,2022-01-05 20:50:13,Spot,Withdrawal,EUR,-100, 22 | 139221274,2022-01-04 00:50:06,Spot,POS savings interest,DOT,0.01416702, 23 | 139221274,2021-10-14 01:31:03,Spot,POS savings interest,ADA,0.0304603, 24 | 139221274,2021-10-14 02:21:10,Spot,POS savings interest,BNB,1.362E-05, 25 | 139221274,2021-10-14 09:35:57,Spot,Savings Interest,DOT,0.00061872, 26 | 139221274,2021-10-14 09:41:14,Spot,Savings Interest,LINK,3.537E-05, -------------------------------------------------------------------------------- /assets/forms/DeleteDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Button from '@mui/material/Button'; 5 | import Dialog from '@mui/material/Dialog'; 6 | import DialogTitle from '@mui/material/DialogTitle'; 7 | import DialogContent from '@mui/material/DialogContent'; 8 | import DialogContentText from '@mui/material/DialogContentText'; 9 | import DialogActions from '@mui/material/DialogActions'; 10 | 11 | 12 | export function DeleteDialog({ open, handleCancel, handleDelete, message, title, canDelete }) { 13 | 14 | return ( 15 | 21 | {title} 22 | 23 | 24 | {message} 25 | 26 | 27 | 28 | 31 | 36 | 37 | 38 | ); 39 | } 40 | 41 | DeleteDialog.propTypes = { 42 | open: PropTypes.bool.isRequired, 43 | handleCancel: PropTypes.func.isRequired, 44 | handleDelete: PropTypes.func.isRequired, 45 | title: PropTypes.string.isRequired, 46 | message: PropTypes.string.isRequired, 47 | canDelete: PropTypes.bool, 48 | }; -------------------------------------------------------------------------------- /finance/migrations/0026_transactionimport_transactionimportrecord.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-12-27 16:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0025_auto_20211102_1819'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='TransactionImport', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('created_at', models.DateTimeField(auto_now_add=True)), 19 | ('integration', models.IntegerField(choices=[(1, 'DEGIRO')])), 20 | ('status', models.IntegerField(choices=[(1, 'Success'), (2, 'Partial success'), (3, 'Failure')])), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='TransactionImportRecord', 25 | fields=[ 26 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('raw_record', models.TextField()), 28 | ('created_new', models.BooleanField(default=False)), 29 | ('successful', models.BooleanField(default=True)), 30 | ('issue_type', models.IntegerField(choices=[(1, 'UNKNOWN_FAILURE'), (2, 'SOLD_BEFORE_BOUGHT'), (3, 'BAD_FORMAT')], null=True)), 31 | ('raw_issue', models.TextField(null=True)), 32 | ('transaction', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_records', to='finance.transaction')), 33 | ('transaction_import', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='finance.transactionimport')), 34 | ], 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const CompressionPlugin = require("compression-webpack-plugin"); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | 8 | module.exports = (env, argv) => { 9 | 10 | return { 11 | 12 | plugins: [ 13 | new CompressionPlugin({ 14 | algorithm: "gzip", 15 | }), 16 | new HtmlWebpackPlugin({ 17 | title: 'Invertimo', 18 | filename: '../templates/base_internal.webpack.html', 19 | template: 'templates/base_internal.tmpl.html', 20 | }), 21 | new webpack.DefinePlugin({ 22 | 'process.env': { 23 | 'PRODUCTION': argv.mode != "development", 24 | } 25 | }) 26 | ], 27 | entry: './assets/index.js', // path to our input file 28 | output: { 29 | filename: '[name].[contenthash].index-bundle.js', // output bundle file name 30 | path: path.resolve(__dirname, './static'), // path to our Django static directory 31 | }, 32 | devtool: 'eval-source-map', 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(js|jsx)$/, 37 | exclude: /node_modules/, 38 | loader: "babel-loader", 39 | options: { 40 | presets: ["@babel/preset-env", "@babel/preset-react"] 41 | 42 | } 43 | }, 44 | { 45 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 46 | type: 'asset/resource', 47 | }, 48 | { 49 | test: /\.css$/i, 50 | use: ['style-loader', 'css-loader'], 51 | }, 52 | ] 53 | }, 54 | optimization: { 55 | usedExports: true, 56 | moduleIds: 'deterministic', 57 | runtimeChunk: 'single', 58 | splitChunks: { 59 | cacheGroups: { 60 | vendor: { 61 | test: /[\\/]node_modules[\\/]/, 62 | name: 'vendors', 63 | chunks: 'all', 64 | }, 65 | }, 66 | }, 67 | }, 68 | 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /finance/migrations/0023_auto_20211028_1835.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-10-28 18:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0022_accountevent_withheld_taxes'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='asset', 16 | options={'ordering': ['-id', 'symbol']}, 17 | ), 18 | migrations.CreateModel( 19 | name='Lot', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('quantity', models.DecimalField(decimal_places=5, max_digits=12)), 23 | ('buy_date', models.DateField()), 24 | ('buy_price', models.DecimalField(decimal_places=5, max_digits=12)), 25 | ('cost_basis_account_currency', models.DecimalField(decimal_places=5, max_digits=12)), 26 | ('sell_date', models.DateField()), 27 | ('sell_price', models.DecimalField(decimal_places=5, max_digits=12, null=True)), 28 | ('sell_basis_account_currency', models.DecimalField(decimal_places=5, max_digits=12, null=True)), 29 | ('realized_gain_account_currency', models.DecimalField(decimal_places=5, max_digits=12, null=True)), 30 | ('buy_transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='buy_lots', to='finance.transaction')), 31 | ('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lots', to='finance.position')), 32 | ('sell_transaction', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sell_lots', to='finance.transaction')), 33 | ], 34 | options={ 35 | 'ordering': ['buy_date'], 36 | }, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # invertimo 2 | 3 | Keep your investment financial records in one place. Make your gains, income and dividend easy to understand even if you use many different brokers, exchanges and different currencies are involved! 4 | 5 | The app is as good as the data you feed it. For now it's integrated with Degiro, biggest EU stock broker. 6 | 7 | - [Live version](https://invertimo.com) 8 | - [Indie Hackers entry](https://www.indiehackers.com/product/invertimo) 9 | 10 | ## Setting up the db 11 | 12 | ``` 13 | python manage.py migrate 14 | python manage.py loaddata finance/fixtures/exchanges.json 15 | ``` 16 | 17 | Data dump was created with: 18 | 19 | ``` 20 | python manage.py dumpdata --natural-primary > finance/fixtures/exchanges.json 21 | ``` 22 | 23 | ## Mypy type checking 24 | 25 | Blog post I used for setup: 26 | 27 | [https://sobolevn.me/2019/08/typechecking-django-and-drf](https://sobolevn.me/2019/08/typechecking-django-and-drf) 28 | 29 | To call it, use 30 | 31 | ```bash 32 | mypy invertimo 33 | ``` 34 | 35 | Setup in `setup.cfg`. 36 | 37 | ## Running without docker 38 | 39 | To run python: 40 | 41 | ``` 42 | # Load environment variables: 43 | source deployment/secrets/local.env 44 | # Activate the virtualenv: 45 | source venv2/bin/activate 46 | # Run the sever: 47 | python3.8 manage.py runserver 48 | 49 | ``` 50 | 51 | To run and compile JS: 52 | 53 | ``` 54 | npx webpack --mode=development --watch 55 | ``` 56 | 57 | For bundle optimization use the bundle opmtimizer, e.g. like this: 58 | 59 | ``` 60 | npx webpack --json > stats.json 61 | npx webpack-bundle-analyzer stats.json static 62 | ``` 63 | 64 | ## Running locally with docker 65 | 66 | See deployment/Readme.md for more info. 67 | 68 | 69 | ## Updating python dependencies 70 | 71 | This project uses [pip-compile](https://github.com/jazzband/pip-tools#example-usage-for-pip-compile). Example workflow, e.g. adding a new package `celery`. 72 | 73 | ```shell 74 | $ pip install celery 75 | ... 76 | $ pip freeze | grep celery 77 | celery==5.2.3 78 | ``` 79 | 80 | Add the celery to the `requirements.in` and then recompile `requirements.txt`. 81 | 82 | ```shell 83 | $ pip-compile > requirements.txt 84 | ``` -------------------------------------------------------------------------------- /assets/TransactionImportList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | 4 | import { 5 | useQuery, 6 | } from 'react-query'; 7 | 8 | 9 | import { getTransactionImportResults } from './api_utils'; 10 | import { TableWithSort } from './components/TableWithSort.js'; 11 | 12 | export default function TransactionImportList() { 13 | 14 | const { status, data, error } = useQuery('imports', getTransactionImportResults); 15 | 16 | const headCells = [ 17 | { id: 'integration', label: 'Import type' }, 18 | { id: 'status', label: 'Status' }, 19 | { id: 'executed_at', label: 'Executed at' }, 20 | { id: 'interaction', label: '' }, 21 | ]; 22 | 23 | 24 | let list =
    Loading import records...
    ; 25 | 26 | 27 | if (status === 'error') { 28 | list = Error: {error.message}; 29 | } else if (status !== 'loading') { 30 | 31 | const rows = data.map(importRecord => { 32 | 33 | let record = { ...importRecord }; 34 | let date = new Date(record.created_at); 35 | record.executed_at = { 36 | displayValue: date.toLocaleDateString(), 37 | comparisonKey: date, 38 | }; 39 | 40 | record.interaction = { 41 | displayValue:
    42 | 45 | 46 |
    47 | }; 48 | 49 | return record; 50 | }); 51 | 52 | list = ; 57 | } 58 | 59 | return ( 60 |
    61 |
    62 |

    63 | Transactions / imports 64 |

    65 |
    66 | {list} 67 |
    68 | 69 | ); 70 | } 71 | 72 | TransactionImportList.propTypes = { 73 | }; 74 | -------------------------------------------------------------------------------- /assets/Reports.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { 6 | Switch, 7 | Route, 8 | useRouteMatch 9 | } from "react-router-dom"; 10 | 11 | 12 | import Icon from '@mui/material/Icon'; 13 | import Button from '@mui/material/Button'; 14 | 15 | import RealizedGainsReport from './RealizedGainsReport.js'; 16 | import IncomeReport from './IncomeReport.js'; 17 | 18 | // TODO: extract out the header specific CSS. 19 | import './transaction_list.css'; 20 | 21 | 22 | export default function Reports(props) { 23 | 24 | let { path } = useRouteMatch(); 25 | 26 | return ( 27 | 28 | 29 |

    Reports

    30 |
    34 | 42 | 50 |
    51 | 52 |
    53 | 54 | 55 | 56 | 57 | 58 | 59 |
    60 | ); 61 | } 62 | 63 | Reports.propTypes = { 64 | positions: PropTypes.array.isRequired, 65 | events: PropTypes.array, 66 | accounts: PropTypes.array.isRequired, 67 | }; 68 | 69 | -------------------------------------------------------------------------------- /finance/assets.py: -------------------------------------------------------------------------------- 1 | from finance import models, prices, tasks 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class AssetRepository: 6 | def __init__(self, exchange : models.Exchange): 7 | self.exchange = exchange 8 | 9 | def get(self, isin: str): 10 | assets = models.Asset.objects.filter(isin=isin, exchange=self.exchange) 11 | if assets: 12 | return assets[0] 13 | 14 | def add( 15 | self, isin: str, symbol: str, currency: models.Currency, country: str, name: str, tracked: bool, user: User, asset_type : models.AssetType 16 | ) -> models.Asset: 17 | asset, _ = models.Asset.objects.get_or_create( 18 | exchange=self.exchange, 19 | isin=isin, 20 | symbol=symbol, 21 | currency=currency, 22 | country=country, 23 | name=name, 24 | tracked=tracked, 25 | added_by=user, 26 | asset_type=asset_type, 27 | ) 28 | asset.full_clean() 29 | return asset 30 | 31 | def add_crypto(self, symbol : str, user: User) -> models.Asset: 32 | # The exchange here should be Other / NA exchange as crypto assets are not tied to 33 | # particular exchanges. 34 | tracked = prices.are_crypto_prices_available(symbol) 35 | asset, _ = models.Asset.objects.get_or_create( 36 | symbol=symbol, 37 | name=symbol, 38 | tracked=tracked, 39 | exchange=self.exchange, 40 | asset_type=models.AssetType.CRYPTO, 41 | currency=models.Currency.USD, 42 | added_by=user if not tracked else None, 43 | ) 44 | asset.full_clean() 45 | return asset 46 | 47 | def add_crypto_from_search(self, symbol, name) -> models.Asset: 48 | asset, _ = models.Asset.objects.get_or_create( 49 | symbol=symbol, 50 | name=name, 51 | tracked=True, 52 | exchange=self.exchange, 53 | asset_type=models.AssetType.CRYPTO, 54 | currency=models.Currency.USD, 55 | ) 56 | asset.full_clean() 57 | return asset 58 | 59 | def get_crypto(self, symbol): 60 | assets = models.Asset.objects.filter(symbol=symbol, exchange=self.exchange) 61 | if assets: 62 | return assets[0] -------------------------------------------------------------------------------- /static/login.css: -------------------------------------------------------------------------------- 1 | .login-card { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #fcfcfc; 7 | margin-bottom: 20px; 8 | -webkit-column-break-inside: avoid; 9 | padding: 24px; 10 | border: 1px solid #d5d7d7; 11 | border-radius: 5px; 12 | text-align: center; 13 | } 14 | 15 | a.google-btn { 16 | display: flex; 17 | /* width: 240px; */ 18 | height: 52px; 19 | align-items: center; 20 | justify-content: space-between; 21 | text-decoration: none; 22 | /* background-color: #4285f4; */ 23 | border-radius: 2px; 24 | box-shadow: 0 3px 4px 0 rgba(0, 0, 0, .15); 25 | margin-top: 1.2em; 26 | margin-bottom: 1.2em; 27 | text-align: start; 28 | color: black; 29 | border: 1px solid gray; 30 | } 31 | 32 | 33 | .google-btn a { 34 | color: black; 35 | } 36 | 37 | .google-btn a:link { 38 | text-decoration: none; 39 | } 40 | 41 | .google-btn a:visited { 42 | text-decoration: none; 43 | } 44 | 45 | .google-btn:hover { 46 | box-shadow: 0 3px 4px 0 rgba(0, 0, 0, .25); 47 | } 48 | 49 | .google-btn:active { 50 | box-shadow: 0 3px 4px 0 rgba(0, 0, 0, .25); 51 | } 52 | 53 | .google-icon-wrapper { 54 | margin-right: 5px; 55 | margin-top: 1px; 56 | margin-left: 1px; 57 | width: 40px; 58 | height: 40px; 59 | border-radius: 20px; 60 | background-color: #fff; 61 | } 62 | 63 | .google-icon { 64 | position: absolute; 65 | margin-top: 11px; 66 | margin-left: 11px; 67 | width: 18px; 68 | height: 18px; 69 | } 70 | 71 | .btn-text { 72 | color: black; 73 | font-size: 16px; 74 | letter-spacing: 0.2px; 75 | font-family: "Roboto"; 76 | padding-top: 10px; 77 | padding-bottom: 10px; 78 | padding-right: 18px; 79 | padding-left: 0px; 80 | display: inline-block; 81 | } 82 | 83 | .main-login { 84 | margin: 20px; 85 | margin-top: 3em; 86 | } 87 | 88 | @media only screen and (min-width: 600px) { 89 | .main-login { 90 | width: 40%; 91 | margin: auto; 92 | margin-top: 5em; 93 | } 94 | } 95 | 96 | @media only screen and (min-width: 1200px) { 97 | 98 | .main { 99 | width: 1000px; 100 | margin-left: auto; 101 | margin-right: auto; 102 | } 103 | } -------------------------------------------------------------------------------- /finance/testing_utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.urls import reverse 3 | from unittest import mock 4 | 5 | 6 | class ViewTestBase: 7 | """ViewTestVase is meant to be used as a base class with the django.test.TestCase 8 | 9 | It offers basic tests for views, so that they don't have to be reimplemented each time. 10 | It doesn't inherit from TestCase because we don't want those tests to run, we only want them 11 | to be run in the child classes. 12 | """ 13 | 14 | URL = None 15 | VIEW_NAME = None 16 | DETAIL_VIEW = False 17 | QUERY_PARAMS = "?" 18 | # Change to None if the view is fine to access while not authenticated. 19 | UNAUTHENTICATED_CODE = 302 # Redirect by default. 20 | 21 | def setUp(self): 22 | self.user = User.objects.create(username="testuser", email="test@example.com") 23 | self.client.force_login(self.user) 24 | 25 | patcher = mock.patch("finance.tasks.collect_prices") 26 | self.addCleanup(patcher.stop) 27 | self.collect_prices_mock = patcher.start() 28 | 29 | def get_url(self): 30 | return self.URL 31 | 32 | def get_reversed_url(self): 33 | return reverse(self.VIEW_NAME) 34 | 35 | def test_url_exists(self): 36 | response = self.client.get(self.get_url() + self.QUERY_PARAMS) 37 | self.assertEqual(response.status_code, 200) 38 | 39 | def test_view_accessible_by_name(self): 40 | response = self.client.get(self.get_reversed_url() + self.QUERY_PARAMS) 41 | self.assertEqual(response.status_code, 200) 42 | 43 | def test_cant_access_without_logging_in(self): 44 | self.client.logout() 45 | response = self.client.get(self.get_reversed_url() + self.QUERY_PARAMS) 46 | # If UNAUTHENTICATE_CODE is overridden to None, it means that it shouldn't 47 | # be disallowed. 48 | if self.UNAUTHENTICATED_CODE: 49 | self.assertEquals(response.status_code, self.UNAUTHENTICATED_CODE) 50 | 51 | def test_cant_access_objects_of_other_users(self): 52 | if self.DETAIL_VIEW: 53 | user2 = User.objects.create( 54 | username="anotheruser", email="test2@example.com" 55 | ) 56 | self.client.force_login(user2) 57 | response = self.client.get(self.get_reversed_url() + self.QUERY_PARAMS) 58 | self.assertEquals(response.status_code, 404) 59 | -------------------------------------------------------------------------------- /assets/components/AreaChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ResponsiveLine } from '@nivo/line'; 4 | import PropTypes from 'prop-types'; 5 | 6 | 7 | export const AreaChart = ({ data }) => { 8 | 9 | return ( { 46 | 47 | const date = slice.points[0].data.x; 48 | return ( 49 |
    56 |

    At {date.toISOString().slice(0, 10)}

    57 | {slice.points.map(point => ( 58 |
    64 | #{point.serieId}: {point.data.yFormatted} 67 |
    68 | ))} 69 |
    70 | ); 71 | }} 72 | />); 73 | }; 74 | 75 | AreaChart.propTypes = { 76 | data: PropTypes.array.isRequired, 77 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | invertimoenv 131 | .vscode 132 | 133 | # javascript related 134 | node_modules/ 135 | static/index-bundle.js 136 | static/index-bundle.js.LICENSE.txt 137 | static/*bundle.js* 138 | templates/base_interna.webpack* 139 | -------------------------------------------------------------------------------- /assets/TransactionImportDetail.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { 4 | useRouteMatch, 5 | useHistory, 6 | } from "react-router-dom"; 7 | 8 | import { 9 | useQuery, 10 | useMutation, 11 | useQueryClient, 12 | } from 'react-query'; 13 | 14 | import Icon from '@mui/material/Icon'; 15 | import Button from '@mui/material/Button'; 16 | 17 | import { TransactionImportResult } from './TransactionImportResult'; 18 | import { getTransactionImportResult, deleteTransactionImportResult } from './api_utils'; 19 | 20 | import { DeleteDialog } from './forms/DeleteDialog.js'; 21 | 22 | 23 | export function TransactionImportDetail() { 24 | let match = useRouteMatch("/transactions/imports/:importId"); 25 | let importId = match.params.importId; 26 | 27 | let history = useHistory(); 28 | 29 | const [deleteDialogOpen, toggleDeleteDialog] = useState(false); 30 | 31 | const queryClient = useQueryClient(); 32 | // Queries 33 | const { status, data, error } = useQuery(['imports', importId], 34 | () => getTransactionImportResult(importId) 35 | ); 36 | 37 | const mutation = useMutation(deleteTransactionImportResult, { 38 | onMutate: variables => { 39 | return variables; 40 | }, 41 | onSuccess: (data, variables) => { 42 | queryClient.invalidateQueries('imports'); 43 | queryClient.invalidateQueries(['imports', variables]); 44 | }, 45 | }); 46 | 47 | const handleDelete = () => { 48 | mutation.mutate(importId); 49 | history.push("/transactions/imports/"); 50 | }; 51 | 52 | return ( 53 |
    54 |
    55 |

    56 | 57 | Transactions / imports / {importId} 58 |

    59 | 63 |
    64 | { 65 | data ? : 66 | (status === "error" ? error.message : "Loading...") 67 | } 68 | 69 | toggleDeleteDialog(false)} open={deleteDialogOpen} canDelete={true} 70 | handleDelete={handleDelete} title="Delete this import?" 71 | message={"Are you sure you want to delete this import? All transactions " + 72 | "and events associated with this import will also be deleted deleted."} 73 | /> 74 |
    75 | 76 | ); 77 | } 78 | 79 | TransactionImportDetail.propTypes = { 80 | }; 81 | -------------------------------------------------------------------------------- /assets/Events.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | Switch, 5 | Route, 6 | useRouteMatch 7 | } from "react-router-dom"; 8 | 9 | import { EventList } from './EventList.js'; 10 | import { EventDetail } from './EventDetail.js'; 11 | import { RecordTransferForm } from './forms/RecordTransferForm.js'; 12 | import { RecordDividendForm } from './forms/RecordDividendForm.js'; 13 | import { RecordCryptoIncomeForm } from './forms/RecordCryptoIncomeForm.js'; 14 | 15 | 16 | export function Events(props) { 17 | 18 | let { path } = useRouteMatch(); 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 |

    Events / record dividend

    27 | 32 |
    33 | 34 |

    Events / record transfer

    35 | 38 |
    39 | 40 |

    Events / record crypto income

    41 |

    This form helps you record income you received from staking or savings interest. If you use Binance, you can instead use automatic import here.

    42 | 46 |
    47 | 48 | 52 | 53 |
    54 | ); 55 | } 56 | 57 | Events.propTypes = { 58 | accounts: PropTypes.array.isRequired, 59 | events: PropTypes.array.isRequired, 60 | positions: PropTypes.array.isRequired, 61 | handleAddEvent: PropTypes.func.isRequired, 62 | handleAddCryptoIncomeEvent: PropTypes.func.isRequired, 63 | handleDeleteEvent: PropTypes.func.isRequired, 64 | }; -------------------------------------------------------------------------------- /assets/position_list.css: -------------------------------------------------------------------------------- 1 | ul.position-list { 2 | margin-top: 0.5em; 3 | margin-bottom: 4em; 4 | box-shadow: 6px 8px 0px 2px #1b98a147; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .position-card { 10 | border-radius: 1px; 11 | border: 1px solid #384a5052; 12 | border-left: 5px solid #384a5052; 13 | padding: 20px; 14 | gap: 20px; 15 | display: grid; 16 | grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 17 | column-gap: 20px; 18 | 19 | } 20 | 21 | .position-card-expanded-content { 22 | padding: 20px; 23 | background: #8282820d; 24 | border: 1px solid #384a5052; 25 | border-left: 5px solid #1b98a1; 26 | border-bottom: 5px solid #384a5052; 27 | margin-bottom: 2em; 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | .position-card-charts { 33 | display: flex; 34 | flex-wrap: wrap; 35 | } 36 | 37 | .position-card-charts-header { 38 | display: flex; 39 | flex-wrap: wrap; 40 | } 41 | 42 | .position-card-charts-header h3 { 43 | margin-right: 2em; 44 | } 45 | 46 | .position-card-active { 47 | border-left: 5px solid #1b98a1; 48 | } 49 | 50 | .position-card>div { 51 | flex-basis: 200px; 52 | display: flex; 53 | flex-direction: column; 54 | } 55 | 56 | .position-name { 57 | display: flex; 58 | flex-direction: column; 59 | flex-basis: 200px; 60 | } 61 | 62 | .position-card .position-name { 63 | display: flex; 64 | flex-direction: column; 65 | flex-basis: 250px; 66 | } 67 | 68 | .position-card-chart { 69 | flex: 1 1 500px; 70 | margin-bottom: 2em; 71 | margin-top: 0em; 72 | /* Avoid the size blowing up, e.g. if last element of the grid. */ 73 | max-width: 650px; 74 | } 75 | 76 | .position-card-chart h3 { 77 | margin-bottom: 0em; 78 | } 79 | 80 | .position-symbol { 81 | font-weight: bold; 82 | font-size: 1.5em; 83 | } 84 | 85 | .position-list-fields { 86 | display: flex; 87 | padding: 0.5em; 88 | padding-left: 45px; 89 | padding-right: 40px; 90 | background: #1b98a1; 91 | color: white; 92 | font-weight: bold; 93 | font-size: 1.2em; 94 | justify-content: space-between; 95 | } 96 | 97 | .position-list-fields li { 98 | padding: 20px; 99 | flex-basis: 200px; 100 | } 101 | 102 | .position-list-fields .position-list-fields-product { 103 | flex-basis: 250px; 104 | } 105 | 106 | .arrow-down-svg.up { 107 | transform: rotate(180deg); 108 | } 109 | 110 | div.position-values { 111 | display: grid; 112 | grid-template-columns: 100px 30px; 113 | } 114 | 115 | .column-stack { 116 | display: flex; 117 | flex-direction: column; 118 | } 119 | 120 | @media screen and (max-width: 500px) { 121 | div.position-values { 122 | display: block; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /assets/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Avatar from '@mui/material/Avatar'; 3 | import Popover from '@mui/material/Popover'; 4 | import makeStyles from '@mui/styles/makeStyles'; 5 | import Icon from '@mui/material/Icon'; 6 | import Chip from '@mui/material/Chip'; 7 | import PropTypes from 'prop-types'; 8 | 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | popoverMenu: { 12 | padding: theme.spacing(2), 13 | paddingBottom: "20px", 14 | display: "flex", 15 | flexDirection: "column", 16 | }, 17 | buttonLogout: { 18 | display: "flex", 19 | alignItems: "center", 20 | justifyContent: "center", 21 | }, 22 | logoutText: { 23 | marginRight: '5px', 24 | }, 25 | accountText: { 26 | fontWeight: 'bold', 27 | }, 28 | whiteBackground: { 29 | background: "white", 30 | } 31 | })); 32 | 33 | 34 | export function Header(props) { 35 | 36 | const handleClick = (event) => { 37 | setAnchorEl(event.currentTarget); 38 | }; 39 | 40 | const handleClose = () => { 41 | setAnchorEl(null); 42 | }; 43 | 44 | const [anchorEl, setAnchorEl] = React.useState(null); 45 | 46 | const open = Boolean(anchorEl); 47 | const id = open ? 'simple-popover' : undefined; 48 | 49 | 50 | const classes = useStyles(); 51 | 52 | return ( 53 |
    54 | 55 | invertimo 56 |
    57 | account_circle} 58 | onClick={handleClick} 59 | variant="outlined" 60 | label={props.email} > 61 | 62 | 77 |
    78 | Logged in as 79 | {props.email} 80 | Log out logout 81 |
    82 | 83 | 84 |
    85 | 86 |
    87 |
    ); 88 | 89 | } 90 | 91 | Header.propTypes = { 92 | email: PropTypes.string.isRequired, 93 | }; 94 | -------------------------------------------------------------------------------- /templates/landing.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% block content %} 4 | 5 |
    6 |

    Keep all your investment related records in one place

    7 |

    Make your future taxes easy

    8 |

    Track your portfolio across different accounts, with assets in different currencies easily.

    9 |

    10 |

    11 |
      12 |
    • 13 |

      Keep track of your transactions

      14 |

      You can import your transactions and info about other events (e.g. dividend payouts, crypto staking payouts or saving interest) 15 | to invertimo using either manual or automated import.

      16 |

      17 | Invertimo supports batch upload from Degiro (biggest European brokerage) and from Binance (one of the biggest crypto exchanges). 18 |

      19 |

      20 | More integrations are planned in the future. 21 |

      22 | Get started for free 23 |
    • 24 |
    • 25 | 26 |
    • 27 |
    28 |
      29 | 30 |
    • 31 |

      See your realized gains for any period easily

      32 |

      It's easy to lose track of all your transactions and get confused with currency exchange rates.

      33 |

      On top of that in some countries such as Ireland, funds are taxed differently than stocks and 34 | you have to deal with deemed disposal and pay taxes from unrealized gains.

      35 |

      Invertimo is perfect for an any investor with diverse assets, e.g in Euro, USD, GBP and crypto.

      36 | Try it 37 | 38 |
    • 39 |
    • 40 | 41 |
    • 42 | 43 | 44 |
    45 |
      46 |
    • 47 |

      48 | What contributed to the value of your portfolio over time? 49 |

      50 |

      51 | Invertimo automatically fetches prices for recognized assets and can help you 52 | better understand your portfolio over time. 53 |

      54 |
    • 55 |
    • 56 | 57 |
    • 58 | 59 |
    60 | 61 |
    62 |

    Developed with ❤️ as open source

    63 |

    64 | The code is open source and can be found on github. 65 |

    66 |

    67 | Developed by @attilczuk. 68 |

    69 |

    Do you have any suggestions? Send them to justyna@invertimo.com

    70 |
    71 | {% endblock %} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "invertimo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest ./assets", 8 | "dev": "webpack --mode development", 9 | "build": "webpack" 10 | }, 11 | "jest": { 12 | "testEnvironment": "jsdom", 13 | "transform": { 14 | ".*": "/node_modules/babel-jest" 15 | }, 16 | "transformIgnorePatterns": [ 17 | "/node_modules/(?!d3-scale-chromatic).+\\.js$" 18 | ], 19 | "verbose": true, 20 | "moduleNameMapper": { 21 | ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "identity-obj-proxy" 22 | } 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/ilonajulczuk/invertimo.git" 27 | }, 28 | "keywords": [], 29 | "author": "", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/ilonajulczuk/invertimo/issues" 33 | }, 34 | "homepage": "https://github.com/ilonajulczuk/invertimo#readme", 35 | "devDependencies": { 36 | "@babel/core": "^7.14.2", 37 | "@babel/plugin-transform-runtime": "^7.13.15", 38 | "@babel/preset-env": "^7.14.2", 39 | "@babel/preset-react": "^7.13.13", 40 | "@nivo/core": "^0.72.0", 41 | "@nivo/line": "^0.72.0", 42 | "@testing-library/dom": "^7.31.2", 43 | "@testing-library/user-event": "^13.2.1", 44 | "babel-jest": "^27.3.1", 45 | "babel-loader": "^8.2.2", 46 | "compression-webpack-plugin": "^9.0.1", 47 | "css-loader": "^5.2.4", 48 | "eslint": "^7.28.0", 49 | "eslint-config-airbnb": "^18.2.1", 50 | "eslint-plugin-import": "^2.23.4", 51 | "eslint-plugin-jest": "^24.3.6", 52 | "eslint-plugin-jsx-a11y": "^6.4.1", 53 | "eslint-plugin-react": "^7.24.0", 54 | "eslint-plugin-react-hooks": "^4.2.0", 55 | "html-webpack-plugin": "^5.5.0", 56 | "identity-obj-proxy": "^3.0.0", 57 | "jest": "^27.3.1", 58 | "optionator": "^0.8.3", 59 | "prettier": "^2.3.0", 60 | "pretty": "^2.0.0", 61 | "style-loader": "^2.0.0", 62 | "webpack": "^5.33.2", 63 | "webpack-bundle-analyzer": "^4.5.0", 64 | "webpack-cli": "^4.6.0" 65 | }, 66 | "dependencies": { 67 | "@babel/runtime": "^7.14.0", 68 | "@date-io/date-fns": "^1.3.13", 69 | "@emotion/styled": "^11.3.0", 70 | "@mui/lab": "^5.0.0-alpha.59", 71 | "@mui/material": "^5.2.4", 72 | "@mui/styles": "^5.2.3", 73 | "@sentry/react": "^6.18.0", 74 | "@sentry/tracing": "^6.18.0", 75 | "@testing-library/jest-dom": "^5.12.0", 76 | "@testing-library/react": "^11.2.7", 77 | "d3-color": "^1.4.1", 78 | "d3-interpolate": "^1.4.0", 79 | "d3-scale-chromatic": "^3.0.0", 80 | "date-fns": "^2.23.0", 81 | "decimal.js": "^10.3.1", 82 | "formik": "^2.2.9", 83 | "js-cookie": "^2.2.1", 84 | "react": "^17.0.2", 85 | "react-dom": "^17.0.2", 86 | "react-query": "^3.39.1", 87 | "react-router-dom": "^5.2.0", 88 | "victory": "^35.7.1", 89 | "yup": "^0.32.9" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /deployment/staging.invertimo.com.nginx.conf: -------------------------------------------------------------------------------- 1 | ## 2 | # You should look at the following URL's in order to grasp a solid understanding 3 | # of Nginx configuration files in order to fully unleash the power of Nginx. 4 | # https://www.nginx.com/resources/wiki/start/ 5 | # https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/ 6 | # https://wiki.debian.org/Nginx/DirectoryStructure 7 | # 8 | # In most cases, administrators will remove this file from sites-enabled/ and 9 | # leave it as reference inside of sites-available where it will continue to be 10 | # updated by the nginx packaging team. 11 | # 12 | # This file will automatically load configuration files provided by other 13 | # applications, such as Drupal or Wordpress. These applications will be made 14 | # available underneath a path with that package name, such as /drupal8. 15 | # 16 | # Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples. 17 | ## 18 | 19 | server { 20 | 21 | # SSL configuration 22 | # 23 | # listen 443 ssl default_server; 24 | # listen [::]:443 ssl default_server; 25 | # 26 | # Note: You should disable gzip for SSL traffic. 27 | # See: https://bugs.debian.org/773332 28 | # 29 | # Read up on ssl_ciphers to ensure a secure configuration. 30 | # See: https://bugs.debian.org/765782 31 | # 32 | # Self signed certs generated by the ssl-cert package 33 | # Don't use them in a production server! 34 | # 35 | # include snippets/snakeoil.conf; 36 | root /var/www/html; 37 | 38 | # Add index.php to the list if you are using PHP 39 | index index.html index.htm index.nginx-debian.html; 40 | server_name staging.invertimo.com; # managed by Certbot 41 | 42 | location /static { 43 | gzip on; 44 | gzip_static on; 45 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 46 | gzip_proxied any; 47 | gzip_vary on; 48 | gzip_comp_level 6; 49 | gzip_buffers 16 8k; 50 | gzip_http_version 1.1; 51 | alias /var/www/staging.invertimo.com/static; 52 | } 53 | location / { 54 | proxy_pass http://localhost:8000/; 55 | proxy_set_header X-Forwarded-Host staging.invertimo.com; 56 | proxy_set_header X-FORWARDED-PROTO https; 57 | proxy_set_header Referer "https://staging.invertimo.com"; 58 | # First attempt to serve request as file, then 59 | # as directory, then fall back to displaying a 404. 60 | } 61 | 62 | 63 | listen [::]:443 ssl; # managed by Certbot 64 | listen 443 ssl; # managed by Certbot 65 | ssl_certificate /etc/letsencrypt/live/staging.invertimo.com/fullchain.pem; # managed by Certbot 66 | ssl_certificate_key /etc/letsencrypt/live/staging.invertimo.com/privkey.pem; # managed by Certbot 67 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 68 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 69 | 70 | } 71 | 72 | server { 73 | if ($host = staging.invertimo.com) { 74 | return 301 https://$host$request_uri; 75 | } # managed by Certbot 76 | 77 | listen 80 ; 78 | listen [::]:80 ; 79 | server_name staging.invertimo.com; 80 | return 404; # managed by Certbot 81 | 82 | 83 | } 84 | -------------------------------------------------------------------------------- /invertimo/urls.py: -------------------------------------------------------------------------------- 1 | """invertimo URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | import debug_toolbar 17 | from django.contrib import admin 18 | from django.urls import include, path 19 | from invertimo import views 20 | from finance.views import ( 21 | AccountsViewSet, 22 | PositionView, 23 | PositionsView, 24 | TransactionsViewSet, 25 | CurrencyExchangeRateView, 26 | AssetPricesView, 27 | AccountEventViewSet, 28 | AssetViewSet, 29 | LotViewSet, 30 | DegiroUploadViewSet, 31 | BinanceUploadViewSet, 32 | TransactionImportViewSet, 33 | ) 34 | from rest_framework import routers 35 | 36 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 37 | 38 | router = routers.DefaultRouter() 39 | router.register(r"transactions", TransactionsViewSet, basename="transaction") 40 | router.register(r"accounts", AccountsViewSet, basename="account") 41 | router.register(r"account-events", AccountEventViewSet, basename="account-event") 42 | router.register(r"assets", AssetViewSet, basename="asset") 43 | router.register(r"lots", LotViewSet, basename="lot") 44 | router.register(r"integrations/degiro/transactions", DegiroUploadViewSet, basename="degiro-transaction-upload") 45 | router.register(r"integrations/binance/transactions", BinanceUploadViewSet, basename="binance-transaction-upload") 46 | 47 | router.register(r"transaction-imports", TransactionImportViewSet, basename="transaction-imports") 48 | 49 | # Used for testing sentry.io integration. 50 | def trigger_error(request): 51 | division_by_zero = 1 / 0 52 | 53 | 54 | urlpatterns = [ 55 | path("", views.index_view, name="index"), 56 | path("admin/", admin.site.urls), 57 | path("login/", views.login_view, name="login"), 58 | path("signup/", views.signup_view, name="signup"), 59 | path("privacy_policy/", views.privacy_policy_view, name="privacy_policy"), 60 | path("logout/", views.logout_view, name="logout"), 61 | path("__debug__/", include(debug_toolbar.urls)), 62 | path("api/", include(router.urls)), 63 | path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), 64 | path("api/positions/", PositionsView.as_view(), name="api-positions"), 65 | path("api/positions//", PositionView.as_view(), name="api-position"), 66 | path("api/currencies/", CurrencyExchangeRateView.as_view(), name="api-currencies"), 67 | path( 68 | "api/assets//prices/", 69 | AssetPricesView.as_view(), 70 | name="api-assets", 71 | ), 72 | path("", include("social_django.urls", namespace="social")), 73 | path('sentry-debug/', trigger_error), 74 | ] 75 | 76 | 77 | urlpatterns += staticfiles_urlpatterns() -------------------------------------------------------------------------------- /deployment/invertimo.com.nginx.conf: -------------------------------------------------------------------------------- 1 | ## 2 | # You should look at the following URL's in order to grasp a solid understanding 3 | # of Nginx configuration files in order to fully unleash the power of Nginx. 4 | # https://www.nginx.com/resources/wiki/start/ 5 | # https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/ 6 | # https://wiki.debian.org/Nginx/DirectoryStructure 7 | # 8 | # In most cases, administrators will remove this file from sites-enabled/ and 9 | # leave it as reference inside of sites-available where it will continue to be 10 | # updated by the nginx packaging team. 11 | # 12 | # This file will automatically load configuration files provided by other 13 | # applications, such as Drupal or Wordpress. These applications will be made 14 | # available underneath a path with that package name, such as /drupal8. 15 | # 16 | # Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples. 17 | ## 18 | 19 | server { 20 | 21 | # SSL configuration 22 | # 23 | # listen 443 ssl default_server; 24 | # listen [::]:443 ssl default_server; 25 | # 26 | # Note: You should disable gzip for SSL traffic. 27 | # See: https://bugs.debian.org/773332 28 | # 29 | # Read up on ssl_ciphers to ensure a secure configuration. 30 | # See: https://bugs.debian.org/765782 31 | # 32 | # Self signed certs generated by the ssl-cert package 33 | # Don't use them in a production server! 34 | # 35 | # include snippets/snakeoil.conf; 36 | root /var/www/html; 37 | 38 | # Add index.php to the list if you are using PHP 39 | index index.html index.htm index.nginx-debian.html; 40 | server_name invertimo.com; # managed by Certbot 41 | 42 | location /static { 43 | gzip on; 44 | gzip_static on; 45 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 46 | gzip_proxied any; 47 | gzip_vary on; 48 | gzip_comp_level 6; 49 | gzip_buffers 16 8k; 50 | gzip_http_version 1.1; 51 | alias /var/www/invertimo.com/static; 52 | } 53 | 54 | location /blog { 55 | gzip on; 56 | gzip_static on; 57 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 58 | gzip_proxied any; 59 | gzip_vary on; 60 | gzip_comp_level 6; 61 | gzip_buffers 16 8k; 62 | gzip_http_version 1.1; 63 | alias /var/www/invertimo.com/blog; 64 | } 65 | 66 | location / { 67 | proxy_pass http://localhost:8000/; 68 | proxy_set_header X-Forwarded-Host invertimo.com; 69 | proxy_set_header X-FORWARDED-PROTO https; 70 | proxy_set_header Referer "https://invertimo.com"; 71 | # First attempt to serve request as file, then 72 | # as directory, then fall back to displaying a 404. 73 | } 74 | 75 | 76 | listen [::]:443 ssl; # managed by Certbot 77 | listen 443 ssl; # managed by Certbot 78 | ssl_certificate /etc/letsencrypt/live/invertimo.com/fullchain.pem; # managed by Certbot 79 | ssl_certificate_key /etc/letsencrypt/live/invertimo.com/privkey.pem; # managed by Certbot 80 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 81 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 82 | 83 | } 84 | 85 | server { 86 | if ($host = invertimo.com) { 87 | return 301 https://$host$request_uri; 88 | } # managed by Certbot 89 | 90 | listen 80 ; 91 | listen [::]:80 ; 92 | server_name invertimo.com; 93 | return 404; # managed by Certbot 94 | 95 | 96 | } 97 | -------------------------------------------------------------------------------- /assets/components/Stepper.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import makeStyles from '@mui/styles/makeStyles'; 4 | import MuiStepper from '@mui/material/Stepper'; 5 | import Step from '@mui/material/Step'; 6 | import StepLabel from '@mui/material/StepLabel'; 7 | import Button from '@mui/material/Button'; 8 | import Typography from '@mui/material/Typography'; 9 | 10 | import useMediaQuery from '@mui/material/useMediaQuery'; 11 | 12 | import PropTypes from 'prop-types'; 13 | 14 | 15 | const useStyles = makeStyles(() => ({ 16 | root: { 17 | width: '100%', 18 | '& .MuiStepper-root': { 19 | paddingLeft: "0px", 20 | }, 21 | '& .MuiStep-root:first-child': { 22 | paddingLeft: "0px", 23 | }, 24 | }, 25 | button: { 26 | marginRight: "10px", 27 | }, 28 | instructions: { 29 | marginTop: "20px", 30 | marginBottom: "20px", 31 | }, 32 | })); 33 | 34 | 35 | export function Stepper(props) { 36 | const classes = useStyles(); 37 | const steps = props.steps; 38 | 39 | let activeStep = props.activeStep; 40 | 41 | const getStepContent = stepNumber => { 42 | return steps[stepNumber].content; 43 | }; 44 | 45 | const lastStep = activeStep === steps.length - 1; 46 | 47 | const verticalStepper = useMediaQuery('(max-width:500px)'); 48 | return ( 49 |
    50 | 51 | {steps.map(step => { 52 | const stepProps = {}; 53 | const labelProps = {}; 54 | return ( 55 | 56 | {step.label} 57 | 58 | ); 59 | })} 60 | 61 |
    62 | {activeStep === steps.length ? ( 63 |
    64 | 65 | All steps completed - you're finished 66 | 67 |
    68 | ) : ( 69 |
    70 |
    71 | {getStepContent(activeStep)} 72 |
    73 |
    74 | 79 | 80 | 89 |
    90 |
    91 | )} 92 |
    93 |
    94 | ); 95 | } 96 | 97 | Stepper.propTypes = { 98 | steps: PropTypes.arrayOf(PropTypes.shape({ 99 | label: PropTypes.string.isRequired, 100 | content: PropTypes.any.isRequired, 101 | nextDisabled: PropTypes.bool, 102 | next: PropTypes.string, 103 | previous: PropTypes.string, 104 | })), 105 | baseUrl: PropTypes.string.isRequired, 106 | finishUrl: PropTypes.string.isRequired, 107 | activeStep: PropTypes.number.isRequired, 108 | }; -------------------------------------------------------------------------------- /assets/components/Stepper.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, unmountComponentAtNode } from "react-dom"; 3 | import { act } from "react-dom/test-utils"; 4 | 5 | import { Stepper } from "./Stepper.js"; 6 | 7 | let container = null; 8 | 9 | describe('stepper with a custom steps', () => { 10 | beforeEach(() => { 11 | // setup a DOM element as a render target. 12 | container = document.createElement("div"); 13 | document.body.appendChild(container); 14 | }); 15 | 16 | afterEach(() => { 17 | // Cleanup on exiting. 18 | unmountComponentAtNode(container); 19 | container.remove(); 20 | container = null; 21 | }); 22 | 23 | it("steppers using various options", () => { 24 | expect.hasAssertions(); 25 | 26 | const baseUrl = "/base/"; 27 | const finishUrl = "/done/"; 28 | 29 | const steps = [ 30 | { 31 | label: 'Step 1', 32 | path: 'step1', 33 | next: 'step2', 34 | content: ( 35 |
    36 |

    Content of step 1

    37 |
    38 | ), 39 | }, 40 | { 41 | label: 'Step 2', 42 | path: 'step2', 43 | previous: 'step1', 44 | next: 'step3', 45 | content: ( 46 |
    47 |

    Content of step 2

    48 |
    49 | ), 50 | }, 51 | { 52 | label: 'Step 3', 53 | path: 'step3', 54 | previous: 'step2', 55 | next: 'step3', 56 | nextDisabled: true, 57 | content: ( 58 |
    59 |

    Content of step 3

    60 |
    61 | ), 62 | }, 63 | ]; 64 | act(() => { 65 | render( 66 | , 68 | container 69 | ); 70 | }); 71 | let buttons = container.querySelectorAll('a'); 72 | expect(buttons.length).toBe(2); 73 | expect(buttons[0].classList).toContain("Mui-disabled"); 74 | expect(buttons[1].href).toBe("http://localhost/base/step2"); 75 | expect(buttons[1].classList).not.toContain("Mui-disabled"); 76 | 77 | let header = container.querySelector('h3'); 78 | expect(header.innerHTML).toContain("step 1"); 79 | 80 | act(() => { 81 | render( 82 | , 84 | container 85 | ); 86 | }); 87 | 88 | buttons = container.querySelectorAll('a'); 89 | expect(buttons.length).toBe(2); 90 | expect(buttons[0].classList).not.toContain("Mui-disabled"); 91 | expect(buttons[0].href).toBe("http://localhost/base/step1"); 92 | expect(buttons[1].href).toBe("http://localhost/base/step3"); 93 | expect(buttons[1].classList).not.toContain("Mui-disabled"); 94 | 95 | header = container.querySelector('h3'); 96 | expect(header.innerHTML).toContain("step 2"); 97 | act(() => { 98 | render( 99 | , 101 | container 102 | ); 103 | }); 104 | 105 | buttons = container.querySelectorAll('a'); 106 | expect(buttons.length).toBe(2); 107 | 108 | expect(buttons[0].href).toBe("http://localhost/base/step2"); 109 | expect(buttons[0].classList).not.toContain("Mui-disabled"); 110 | expect(buttons[1].href).toBe("http://localhost/done/"); 111 | expect(buttons[1].classList).toContain("Mui-disabled"); 112 | 113 | header = container.querySelector('h3'); 114 | expect(header.innerHTML).toContain("step 3"); 115 | }); 116 | 117 | }); -------------------------------------------------------------------------------- /assets/LotList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { toSymbol } from './currencies.js'; 5 | import { TableWithSort } from './components/TableWithSort.js'; 6 | import { ErrorBoundary } from './error_utils.js'; 7 | 8 | import { formatDistance } from 'date-fns'; 9 | 10 | const duration = s => formatDistance(0, s, { includeSeconds: false }); 11 | 12 | export function LotList(props) { 13 | 14 | const positionCurrency = toSymbol(props.position.asset.currency); 15 | const accountCurrency = toSymbol(props.account.currency); 16 | let headCells = [ 17 | { id: 'quantity', label: 'Quantity' }, 18 | { id: 'buy_price', label: `Buy Price (${positionCurrency})` }, 19 | { id: 'sell_price', label: `Sell Price (${positionCurrency})` }, 20 | { id: 'cost_basis_account_currency', label: `Cost Basis (${accountCurrency})` }, 21 | { id: 'sell_basis_account_currency', label: `Sell Basis (${accountCurrency})` }, 22 | { id: 'realized_gain_account_currency', label: `Realized Gain (${accountCurrency})` }, 23 | { id: 'transaction_dates', label: 'Transaction Dates' }, 24 | ]; 25 | if (props.holdTime) { 26 | headCells.push({ id: 'hold_time', label: 'Hold time' }); 27 | } 28 | headCells.push( 29 | { id: 'transactions', label: 'Transactions' } 30 | ); 31 | 32 | const lots = props.lots.map(lot => { 33 | let row = { ...lot }; 34 | let buyDate = new Date(row.buy_date); 35 | let sellDate = new Date(row.sell_date); 36 | row.transaction_dates = { 37 | displayValue: , 41 | comparisonKey: buyDate, 42 | }; 43 | const holdTime = sellDate - buyDate; 44 | row.hold_time = { 45 | displayValue: duration(holdTime), 46 | comparisonKey: holdTime, 47 | }; 48 | 49 | row.quantity = Number(row.quantity); 50 | row.buy_price = Number(row.buy_price); 51 | row.sell_price = Number(row.sell_price); 52 | row.cost_basis_account_currency = Number(row.cost_basis_account_currency); 53 | row.sell_basis_account_currency = Number(row.sell_basis_account_currency); 54 | row.realized_gain_account_currency = Number(row.realized_gain_account_currency); 55 | 56 | row.transactions = { 57 | displayValue: , 61 | comparisonKey: row.buy_transaction, 62 | }; 63 | return row; 64 | }); 65 | 66 | 67 | return ( 68 | 69 | 74 | 75 | ); 76 | } 77 | 78 | LotList.propTypes = { 79 | lots: PropTypes.arrayOf(PropTypes.shape({ 80 | id: PropTypes.number.isRequired, 81 | quantity: PropTypes.string.isRequired, 82 | buy_date: PropTypes.string.isRequired, 83 | buy_price: PropTypes.string.isRequired, 84 | cost_basis_account_currency: PropTypes.string.isRequired, 85 | sell_date: PropTypes.string.isRequired, 86 | sell_price: PropTypes.string.isRequired, 87 | sell_basis_account_currency: PropTypes.string.isRequired, 88 | realized_gain_account_currency: PropTypes.string.isRequired, 89 | position: PropTypes.number.isRequired, 90 | buy_transaction: PropTypes.number.isRequired, 91 | sell_transaction: PropTypes.number.isRequired, 92 | })).isRequired, 93 | position: PropTypes.shape({ asset: PropTypes.object.isRequired }).isRequired, 94 | account: PropTypes.shape({ currency: PropTypes.string.isRequired }), 95 | holdTime: PropTypes.bool.isRequired, 96 | }; -------------------------------------------------------------------------------- /finance/test_prices.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | from django.test import TestCase 4 | 5 | from finance import prices 6 | from finance import models 7 | from finance import utils 8 | 9 | 10 | class TestPrices(TestCase): 11 | def setUp(self): 12 | super().setUp() 13 | 14 | self.from_currency = models.Currency.EUR 15 | self.to_currency = models.Currency.USD 16 | other_currency = models.Currency.GBP 17 | 18 | dates = utils.generate_date_intervals( 19 | from_date=datetime.date.fromisoformat("2021-03-01"), to_date=datetime.date.fromisoformat("2021-05-01") 20 | ) 21 | for i, date in enumerate(dates): 22 | models.CurrencyExchangeRate.objects.create( 23 | from_currency=self.from_currency, 24 | to_currency=self.to_currency, 25 | date=date, 26 | value=0.8 + 0.1 * (i % 5), 27 | ) 28 | models.CurrencyExchangeRate.objects.create( 29 | from_currency=self.from_currency, 30 | to_currency=other_currency, 31 | date=date, 32 | value=0.9 + 0.1 * (i % 5), 33 | ) 34 | 35 | other_sparse_dates = utils.generate_date_intervals( 36 | from_date=datetime.date.fromisoformat("2020-03-01"), 37 | to_date=datetime.date.fromisoformat("2020-05-01"), 38 | output_period=datetime.timedelta(days=3), 39 | ) 40 | 41 | for i, date in enumerate(other_sparse_dates): 42 | models.CurrencyExchangeRate.objects.create( 43 | from_currency=self.from_currency, 44 | to_currency=self.to_currency, 45 | date=date, 46 | value=1.8 + 0.1 * (i % 5), 47 | ) 48 | 49 | def test_exchange_rate_present(self): 50 | 51 | rate = prices.get_closest_exchange_rate( 52 | date=datetime.date.fromisoformat("2021-04-03"), 53 | from_currency=self.from_currency, 54 | to_currency=self.to_currency, 55 | ) 56 | self.assertEqual(rate.date, datetime.date.fromisoformat("2021-04-03")) 57 | self.assertEqual(rate.from_currency, self.from_currency) 58 | self.assertEqual(rate.to_currency, self.to_currency) 59 | self.assertEqual(rate.value, decimal.Decimal("1.1")) 60 | 61 | def test_exchange_rate_sparse_range(self): 62 | rate = prices.get_closest_exchange_rate( 63 | date=datetime.date.fromisoformat("2020-04-03"), 64 | from_currency=self.from_currency, 65 | to_currency=self.to_currency, 66 | ) 67 | # Should use first date before it. 68 | self.assertEqual(rate.date, datetime.date.fromisoformat("2020-04-01")) 69 | self.assertEqual(rate.from_currency, self.from_currency) 70 | self.assertEqual(rate.to_currency, self.to_currency) 71 | self.assertEqual(rate.value, decimal.Decimal("1.8")) 72 | 73 | 74 | def test_exchange_too_early(self): 75 | rate = prices.get_closest_exchange_rate( 76 | date=datetime.date.fromisoformat("2000-04-03"), 77 | from_currency=self.from_currency, 78 | to_currency=self.to_currency, 79 | ) 80 | # Will use first date available. 81 | self.assertEqual(rate.date, datetime.date.fromisoformat("2020-03-02")) 82 | self.assertEqual(rate.from_currency, self.from_currency) 83 | self.assertEqual(rate.to_currency, self.to_currency) 84 | self.assertEqual(rate.value, decimal.Decimal("1.8")) 85 | 86 | def test_exchange_too_late(self): 87 | rate = prices.get_closest_exchange_rate( 88 | date=datetime.date.fromisoformat("2021-11-03"), 89 | from_currency=self.from_currency, 90 | to_currency=self.to_currency, 91 | ) 92 | # Will use last date available. 93 | self.assertEqual(rate.date, datetime.date.fromisoformat("2021-05-01")) 94 | self.assertEqual(rate.from_currency, self.from_currency) 95 | self.assertEqual(rate.to_currency, self.to_currency) 96 | self.assertEqual(rate.value, decimal.Decimal("0.8")) 97 | 98 | def test_generating_currency_pairs(self): 99 | symbol_to_currencies = prices.generate_symbol_to_currency_pairs(["EUR", "GBP", "USD"]) 100 | self.assertEqual(len(symbol_to_currencies), 6) 101 | 102 | all_symbol_to_currencies = prices.generate_symbol_to_currency_pairs(prices.currencies) 103 | self.assertEqual(len(all_symbol_to_currencies), len(prices.currencies) * (len(prices.currencies) - 1)) -------------------------------------------------------------------------------- /finance/migrations/0038_auto_20220203_1112.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2022-02-03 11:12 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('finance', '0037_alter_eventimportrecord_transaction'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='account', 16 | name='balance', 17 | field=models.DecimalField(decimal_places=10, default=0, max_digits=20), 18 | ), 19 | migrations.AlterField( 20 | model_name='accountevent', 21 | name='amount', 22 | field=models.DecimalField(decimal_places=10, max_digits=20), 23 | ), 24 | migrations.AlterField( 25 | model_name='accountevent', 26 | name='withheld_taxes', 27 | field=models.DecimalField(decimal_places=10, default=0, max_digits=20), 28 | ), 29 | migrations.AlterField( 30 | model_name='currencyexchangerate', 31 | name='value', 32 | field=models.DecimalField(decimal_places=10, max_digits=20), 33 | ), 34 | migrations.AlterField( 35 | model_name='lot', 36 | name='buy_price', 37 | field=models.DecimalField(decimal_places=10, max_digits=20), 38 | ), 39 | migrations.AlterField( 40 | model_name='lot', 41 | name='cost_basis_account_currency', 42 | field=models.DecimalField(decimal_places=10, max_digits=20), 43 | ), 44 | migrations.AlterField( 45 | model_name='lot', 46 | name='quantity', 47 | field=models.DecimalField(decimal_places=10, max_digits=20), 48 | ), 49 | migrations.AlterField( 50 | model_name='lot', 51 | name='realized_gain_account_currency', 52 | field=models.DecimalField(decimal_places=10, max_digits=20, null=True), 53 | ), 54 | migrations.AlterField( 55 | model_name='lot', 56 | name='sell_basis_account_currency', 57 | field=models.DecimalField(decimal_places=10, max_digits=20, null=True), 58 | ), 59 | migrations.AlterField( 60 | model_name='lot', 61 | name='sell_price', 62 | field=models.DecimalField(decimal_places=10, max_digits=20, null=True), 63 | ), 64 | migrations.AlterField( 65 | model_name='position', 66 | name='cost_basis', 67 | field=models.DecimalField(decimal_places=10, default=0, max_digits=20), 68 | ), 69 | migrations.AlterField( 70 | model_name='position', 71 | name='quantity', 72 | field=models.DecimalField(decimal_places=10, default=0, max_digits=20), 73 | ), 74 | migrations.AlterField( 75 | model_name='position', 76 | name='realized_gain', 77 | field=models.DecimalField(decimal_places=10, default=0, max_digits=20), 78 | ), 79 | migrations.AlterField( 80 | model_name='pricehistory', 81 | name='value', 82 | field=models.DecimalField(decimal_places=10, max_digits=20), 83 | ), 84 | migrations.AlterField( 85 | model_name='transaction', 86 | name='local_value', 87 | field=models.DecimalField(decimal_places=10, max_digits=20), 88 | ), 89 | migrations.AlterField( 90 | model_name='transaction', 91 | name='price', 92 | field=models.DecimalField(decimal_places=10, max_digits=20), 93 | ), 94 | migrations.AlterField( 95 | model_name='transaction', 96 | name='total_in_account_currency', 97 | field=models.DecimalField(decimal_places=10, max_digits=20), 98 | ), 99 | migrations.AlterField( 100 | model_name='transaction', 101 | name='transaction_costs', 102 | field=models.DecimalField(decimal_places=10, max_digits=20, null=True), 103 | ), 104 | migrations.AlterField( 105 | model_name='transaction', 106 | name='value_in_account_currency', 107 | field=models.DecimalField(decimal_places=10, max_digits=20), 108 | ), 109 | migrations.AlterField( 110 | model_name='transactionimportrecord', 111 | name='transaction_import', 112 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='finance.transactionimport'), 113 | ), 114 | ] 115 | -------------------------------------------------------------------------------- /assets/TransactionImportResult.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | import PropTypes from 'prop-types'; 5 | 6 | import Accordion from '@mui/material/Accordion'; 7 | import AccordionSummary from '@mui/material/AccordionSummary'; 8 | import AccordionDetails from '@mui/material/AccordionDetails'; 9 | import Icon from '@mui/material/Icon'; 10 | import Alert from '@mui/material/Alert'; 11 | import format from 'date-fns/format'; 12 | import { filter } from 'lodash'; 13 | import { TransactionImportRecordReferencingTransaction } from './TransactionImportRecord'; 14 | 15 | 16 | export function TransactionImportResult(props) { 17 | const status = props.importResult.status; 18 | const integration = props.importResult.integration; 19 | const severity = status === "Success" ? "success" : ( 20 | status == "Partial success" ? "warning" : "error"); 21 | 22 | const summary = status === "Success" ? `Import from ${integration} succeeded` : ( 23 | status == "Partial success" ? `Import from ${integration} partially succeeded` : `Import from ${integration} failed`); 24 | 25 | const createdAt = format(new Date(props.importResult.created_at), "yyyy-MM-dd k:m O"); 26 | 27 | const successfulRawRecords = filter(props.importResult.records, "successful"); 28 | const successfulRawRecordsDuplicates = filter(successfulRawRecords, o => !o.created_new); 29 | 30 | const failedRawRecords = filter(props.importResult.records, 31 | record => !record.successful 32 | ); 33 | 34 | const successfulRecords = successfulRawRecords.map(record => ); 35 | const failedRecords = failedRawRecords.map(record => ); 36 | 37 | const successfulRawEventRecords = filter(props.importResult.event_records, "successful"); 38 | const successfulRawEventRecordsDuplicates = filter(successfulRawEventRecords, o => !o.created_new); 39 | 40 | const failedRawEventRecords = filter(props.importResult.event_records, 41 | record => !record.successful 42 | ); 43 | 44 | const successfulEventRecords = successfulRawEventRecords.map(record => ); 45 | const failedEventRecords = failedRawEventRecords.map(record => ); 46 | 47 | return ( 48 | 49 | 50 | 51 | 59 | expand_more} 61 | aria-controls="import-result-content" 62 | id="import-result-header"> 63 | {summary} 64 | 65 | 66 | 67 |

    68 | Executed at: {createdAt} 69 |

    70 |

    Transactions

    71 |

    {successfulRawRecords.length} successful records, {successfulRawRecordsDuplicates.length} of which duplicates.

    72 |

    {failedRawRecords.length} failed records.

    73 | 74 | {successfulRawRecords.length ?

    Successful records

    : null} 75 | {successfulRecords} 76 | {failedRawRecords.length ?

    Failed records

    : null} 77 | {failedRecords} 78 |

    Events

    79 |

    {successfulRawEventRecords.length} successful records, {successfulRawEventRecordsDuplicates.length} of which duplicates.

    80 |

    {failedRawEventRecords.length} failed records.

    81 | 82 | {successfulRawEventRecords.length ?

    Successful records

    : null} 83 | {successfulEventRecords} 84 | {failedRawEventRecords.length ?

    Failed records

    : null} 85 | {failedEventRecords} 86 |
    87 |
    88 | ); 89 | } 90 | 91 | TransactionImportResult.propTypes = { 92 | importResult: PropTypes.shape({ 93 | records: PropTypes.array.isRequired, 94 | event_records: PropTypes.array.isRequired, 95 | status: PropTypes.string.isRequired, 96 | integration: PropTypes.string.isRequired, 97 | created_at: PropTypes.string.isRequired, 98 | }) 99 | }; -------------------------------------------------------------------------------- /assets/forms/CreateAccountForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '@mui/material/Button'; 4 | 5 | import PropTypes from 'prop-types'; 6 | 7 | import { Formik, Form } from 'formik'; 8 | import * as yup from 'yup'; 9 | 10 | import { FormikTextField, FormikSelectField } from './muiformik.js'; 11 | import { currencyValues } from '../currencies.js'; 12 | import { useStyles } from './styles.js'; 13 | 14 | 15 | const validationSchema = yup.object({ 16 | name: yup 17 | .string('Enter the name for the account, like \'degiro\'') 18 | .required('Account name is required'), 19 | currency: yup 20 | .string('Enter the currency') 21 | .oneOf(currencyValues) 22 | .required('Currency is required'), 23 | }); 24 | 25 | 26 | export function CreateAccountForm(props) { 27 | 28 | const classes = useStyles(); 29 | 30 | const initialValues = { 31 | currency: props.defaultCurrency, 32 | name: "", 33 | }; 34 | const onSubmit = async (values, { setErrors, resetForm }) => { 35 | try { 36 | const result = await props.handleSubmit(values); 37 | if (result.ok) { 38 | resetForm(); 39 | if (result.callback) { 40 | result.callback(); 41 | } 42 | } else { 43 | if (result.errors) { 44 | setErrors(result.errors); 45 | } else if (result.message) { 46 | alert(result.message); 47 | } 48 | } 49 | } catch (e) { 50 | alert(e); 51 | } 52 | }; 53 | 54 | const submitButtonText = props.hasAccounts ? "Create another account" : "Create account"; 55 | 56 | const currencyOptions = [ 57 | { 58 | value: "USD", 59 | label: "$ USD", 60 | }, 61 | { 62 | value: "EUR", 63 | label: "€ EUR", 64 | }, 65 | { 66 | value: "GBP", 67 | label: "£ GBP", 68 | }, 69 | ]; 70 | const currencyHelperText = `Main currency used by this account (some positions, e.g. stocks might trade in different currencies)`; 71 | return ( 72 | 73 | 79 | {({ isSubmitting }) => ( 80 |
    81 |

    ⚠️ Important: Account currency is a currency in which all the gains and income will be computed in, 82 | so chose it carefully. We recommend to pick the currency that you are taxed 83 | in and keep the same account currency across all your accounts for simplicity.

    84 | 85 | 86 |
    87 | 97 | 98 | 108 |
    109 | 110 |
    111 | 119 |
    120 | 121 | 122 |
    123 | )} 124 |
    125 | ); 126 | } 127 | 128 | CreateAccountForm.propTypes = { 129 | handleSubmit: PropTypes.func.isRequired, 130 | hasAccounts: PropTypes.bool.isRequired, 131 | defaultCurrency: PropTypes.oneOf(["EUR", "USD", "GBP"]).isRequired, 132 | }; -------------------------------------------------------------------------------- /finance/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-18 21:36 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Account', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('currency', models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD')], default=1)), 22 | ('nickname', models.CharField(max_length=200)), 23 | ('description', models.TextField()), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='Exchange', 29 | fields=[ 30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('name', models.CharField(max_length=200)), 32 | ], 33 | ), 34 | migrations.CreateModel( 35 | name='Position', 36 | fields=[ 37 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 38 | ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to='finance.account')), 39 | ], 40 | ), 41 | migrations.CreateModel( 42 | name='Transaction', 43 | fields=[ 44 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 45 | ('executed_at', models.DateTimeField()), 46 | ('quantity', models.DecimalField(decimal_places=2, max_digits=10)), 47 | ('price', models.DecimalField(decimal_places=5, max_digits=12)), 48 | ('transaction_costs', models.DecimalField(decimal_places=5, max_digits=12)), 49 | ('local_value', models.DecimalField(decimal_places=5, max_digits=12)), 50 | ('value_in_account_currency', models.DecimalField(decimal_places=5, max_digits=12)), 51 | ('order_id', models.CharField(max_length=200, null=True)), 52 | ('last_modified', models.DateTimeField(auto_now=True)), 53 | ('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='finance.position')), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Security', 58 | fields=[ 59 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('isin', models.CharField(max_length=30)), 61 | ('symbol', models.CharField(max_length=30)), 62 | ('currency', models.IntegerField(choices=[(1, 'EUR'), (2, 'GBP'), (3, 'USD')], default=3)), 63 | ('country', models.CharField(max_length=200, null=True)), 64 | ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='securities', to='finance.exchange')), 65 | ], 66 | ), 67 | migrations.AddField( 68 | model_name='position', 69 | name='security', 70 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to='finance.security'), 71 | ), 72 | migrations.CreateModel( 73 | name='ExchangeIdentifier', 74 | fields=[ 75 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 76 | ('id_type', models.IntegerField(choices=[(1, 'CODE'), (2, 'MIC'), (3, 'SEGMENT MIC')])), 77 | ('value', models.CharField(max_length=20)), 78 | ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identifiers', to='finance.exchange')), 79 | ], 80 | ), 81 | migrations.CreateModel( 82 | name='AccountEvent', 83 | fields=[ 84 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 85 | ('executed_at', models.DateTimeField()), 86 | ('last_modified', models.DateTimeField(auto_now=True)), 87 | ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='finance.account')), 88 | ('position', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='finance.position')), 89 | ], 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /assets/components/SplitButtonNav.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import ButtonGroup from '@mui/material/ButtonGroup'; 4 | import Icon from '@mui/material/Icon'; 5 | import ClickAwayListener from '@mui/material/ClickAwayListener'; 6 | import Grow from '@mui/material/Grow'; 7 | import Paper from '@mui/material/Paper'; 8 | import Popper from '@mui/material/Popper'; 9 | import MenuItem from '@mui/material/MenuItem'; 10 | import MenuList from '@mui/material/MenuList'; 11 | 12 | import { 13 | useHistory, 14 | } from "react-router-dom"; 15 | 16 | import PropTypes from 'prop-types'; 17 | 18 | export default function SplitButtonNav({ options, color }) { 19 | 20 | let history = useHistory(); 21 | const [open, setOpen] = React.useState(false); 22 | const anchorRef = React.useRef(null); 23 | const [selectedIndex, setSelectedIndex] = React.useState(0); 24 | 25 | const handleClick = () => { 26 | history.push(options[selectedIndex].link); 27 | }; 28 | 29 | const handleMenuItemClick = (event, index) => { 30 | setSelectedIndex(index); 31 | setOpen(false); 32 | history.push(options[index].link); 33 | }; 34 | 35 | const handleToggle = () => { 36 | setOpen((prevOpen) => !prevOpen); 37 | }; 38 | 39 | const handleClose = (event) => { 40 | if (anchorRef.current && anchorRef.current.contains(event.target)) { 41 | return; 42 | } 43 | setOpen(false); 44 | }; 45 | 46 | return ( 47 | 48 | 49 | 50 | 59 | 60 | 71 | {({ TransitionProps, placement }) => ( 72 | 79 | 82 | 83 | 91 | {options.map((option, index) => ( 92 | handleMenuItemClick(event, index)} 101 | > 102 | {option.label} 103 | 104 | ))} 105 | 106 | 107 | 108 | 109 | )} 110 | 111 | 112 | ); 113 | } 114 | 115 | SplitButtonNav.propTypes = { 116 | options: PropTypes.arrayOf( 117 | PropTypes.shape({ 118 | label: PropTypes.any.isRequired, 119 | link: PropTypes.string.isRequired 120 | })).isRequired, 121 | color: PropTypes.string, 122 | }; -------------------------------------------------------------------------------- /assets/PositionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './position_list.css'; 3 | import PropTypes from 'prop-types'; 4 | import { TableWithSort } from './components/TableWithSort.js'; 5 | import { toSymbol } from './currencies.js'; 6 | import { PositionDetail } from './PositionDetail.js'; 7 | import { PositionLink } from './components/PositionLink.js'; 8 | import { ErrorBoundary } from './error_utils.js'; 9 | 10 | 11 | import Button from '@mui/material/Button'; 12 | import { 13 | Switch, 14 | Route, 15 | useRouteMatch 16 | } from "react-router-dom"; 17 | 18 | 19 | export default function PositionList(props) { 20 | 21 | const positionHeadCells = [ 22 | { id: 'product', label: 'Product' }, 23 | { id: 'assetType', label: 'Asset type'}, 24 | { id: 'exchange', label: 'Exchange' }, 25 | { id: 'quantity', label: 'Quantity' }, 26 | { id: 'price', label: 'Price' }, 27 | { id: 'value', label: 'Value' }, 28 | { id: 'gain', label: 'Gain'}, 29 | { id: 'interaction', label: '' }, 30 | ]; 31 | 32 | let accountsById = new Map(props.accounts.map(account => [account.id, account])); 33 | 34 | let { path } = useRouteMatch(); 35 | 36 | const positions = props.positions.map(position => { 37 | let positionRow = { "id": position.id }; 38 | 39 | let account = accountsById.get(position.account); 40 | const accountCurrency = toSymbol(account.currency); 41 | const data = position; 42 | const quantity = Number(data.quantity); 43 | const price = Number(data.latest_price); 44 | const value = Math.round(100 * quantity * price) / 100; 45 | 46 | let displayConvertedValue = (data.asset.currency != account.currency && data.latest_exchange_rate); 47 | 48 | positionRow.product = { 49 | displayValue: , 50 | comparisonKey: data.asset.symbol, 51 | }; 52 | positionRow.assetType = data.asset.asset_type; 53 | positionRow.exchange = data.asset.exchange.name; 54 | positionRow.quantity = quantity; 55 | positionRow.price = { 56 | displayValue: (
    57 | As of {data.latest_price_date} 58 | {price}{toSymbol(data.asset.currency)}
    ), 59 | comparisonKey: Number(price), 60 | }; 61 | 62 | let valueAccountCurrency = value; 63 | if (displayConvertedValue) { 64 | valueAccountCurrency = Math.round(100 * value * data.latest_exchange_rate) / 100; 65 | } 66 | positionRow.value = { 67 | displayValue: (
    68 | 69 | {value}{toSymbol(data.asset.currency)} 70 | 71 | 72 | {displayConvertedValue ? Math.round(100 * value * data.latest_exchange_rate) / 100 : ""} 73 | {displayConvertedValue ? "" + accountCurrency : ""} 74 | 75 |
    ), 76 | comparisonKey: valueAccountCurrency, 77 | }; 78 | 79 | const unrealizedGain = Math.round(100 * (Number(position.cost_basis) + Number(valueAccountCurrency))) / 100; 80 | positionRow.gain = { 81 | displayValue: (
    82 | Unrealized gain 83 | {unrealizedGain} {accountCurrency} 84 | Realized gain 85 | {Number(position.realized_gain)} {accountCurrency} 86 |
    ), 87 | comparisonKey: unrealizedGain, 88 | }; 89 | positionRow.interaction = { 90 | displayValue:
    91 | 94 |
    95 | }; 96 | return positionRow; 97 | }); 98 | 99 | return ( 100 |
    101 | 102 | 103 |

    Positions

    104 | 105 | 110 | 111 |
    112 | 113 | 114 | 115 | 116 | 117 |
    118 |
    119 | 120 | ); 121 | } 122 | 123 | PositionList.propTypes = { 124 | positions: PropTypes.array.isRequired, 125 | accounts: PropTypes.array.isRequired, 126 | getPositionDetail: PropTypes.func.isRequired, 127 | }; -------------------------------------------------------------------------------- /assets/TransactionDetail.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, unmountComponentAtNode } from "react-dom"; 3 | import { act } from "react-dom/test-utils"; 4 | import pretty from "pretty"; 5 | 6 | // The import below is necessary for async/await to work. 7 | // eslint-disable-next-line no-unused-vars 8 | import regeneratorRuntime from "regenerator-runtime"; 9 | import { TransactionDetail } from "./TransactionDetail.js"; 10 | import { MemoryRouter } from "react-router-dom"; 11 | let container = null; 12 | 13 | describe('transaction detail tests', () => { 14 | 15 | beforeEach(() => { 16 | // setup a DOM element as a render target. 17 | container = document.createElement("div"); 18 | document.body.appendChild(container); 19 | }); 20 | 21 | afterEach(() => { 22 | // Cleanup on exiting. 23 | unmountComponentAtNode(container); 24 | container.remove(); 25 | container = null; 26 | }); 27 | 28 | const accounts = [ 29 | { 30 | id: 1, 31 | nickname: "First account", 32 | currency: "EUR", 33 | }, 34 | { 35 | id: 77, 36 | nickname: "Another account", 37 | currency: "USD", 38 | }, 39 | ]; 40 | 41 | const transactions = [ 42 | { 43 | id: 1, 44 | quantity: "33", 45 | price: "3.44", 46 | local_value: "-123.11", 47 | transaction_costs: "-0.51", 48 | executed_at: "2021-08-11", 49 | value_in_account_currency: "-123.22", 50 | position: { 51 | id: 11, 52 | account: 1, 53 | asset: { 54 | "symbol": "DIS", 55 | "currency": "USD", 56 | "name": 'The Walt Disney Company', 57 | "id": 123, 58 | "isin": "", 59 | "tracked": true, 60 | "exchange": { 61 | name: "USA Stocks" 62 | }, 63 | "asset_type": "Stock", 64 | }, 65 | }, 66 | events: [], 67 | records: [ 68 | { 69 | "id": 342, 70 | "transaction": 555, 71 | "raw_record": ",56\nDate,02-12-2020\nTime,09:04\nProduct,JPMORGAN G\nISIN,GB00B18JK166\nReference,LSE\nVenue,XLON\nQuantity,152\nPrice,296.0\nLocal value,-44992.0\nValue,-498.41\nExchange rate,90.1803\nTransaction and/or third,-4.25\nTotal,-502.66\nOrder ID,c4a5d067-214c-4a2b-96db-6e3838e8c5ee\nPrice currency,GBX\nLocal value currency,GBX\nValue currency,EUR\nTransaction costs currency,EUR\nTotal currency,EUR\nTransaction costs,-4.25\nDatetime,2020-12-02 09:04:00+00:00\n", 72 | "created_new": false, 73 | "successful": true, 74 | "issue_type": null, 75 | "raw_issue": null, 76 | "transaction_import": 10, 77 | "created_at": "2021-12-28T16:59:30.701718Z", 78 | "integration": "DEGIRO" 79 | }, 80 | ], 81 | event_records: [ 82 | { 83 | "id": 9, 84 | "transaction": 1115, 85 | "event": 45, 86 | "event_type": "STAKING_INTEREST", 87 | "raw_record": ",20\nUser_ID,139221274\nUTC_Time,2022-01-04 00:50:06\nAccount,Spot\nOperation,POS savings interest\nCoin,DOT\nChange,0.01416702\nRemark,\n", 88 | "created_new": true, 89 | "successful": true, 90 | "issue_type": null, 91 | "raw_issue": null, 92 | "transaction_import": 52, 93 | "created_at": "2022-01-31T18:04:51.005459Z", 94 | "integration": "BINANCE_CSV" 95 | } 96 | ] 97 | } 98 | ]; 99 | 100 | const handleDeleteTransaction = jest.fn(); 101 | const handleCorrectTransaction = jest.fn(); 102 | it("renders transaction with correct actions", async () => { 103 | expect.hasAssertions(); 104 | await act(async () => { 105 | render( 106 | 112 | , container); 113 | }); 114 | expect(pretty(container.innerHTML)).toContain('The Walt Disney Company'); 115 | expect(pretty(container.innerHTML)).toContain('/transactions/1/correct'); 116 | expect(pretty(container.innerHTML)).toContain('/transactions/1/delete'); 117 | 118 | // Related to the `records` and `event_records`. 119 | expect(pretty(container.innerHTML)).toContain('/transactions/imports/52'); 120 | expect(pretty(container.innerHTML)).toContain('/transactions/imports/10'); 121 | expect(pretty(container.innerHTML)).toContain('STAKING_INTEREST'); 122 | }); 123 | 124 | }); -------------------------------------------------------------------------------- /assets/TransactionImportRecord.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | 5 | import Icon from '@mui/material/Icon'; 6 | import Accordion from '@mui/material/Accordion'; 7 | import AccordionSummary from '@mui/material/AccordionSummary'; 8 | import AccordionDetails from '@mui/material/AccordionDetails'; 9 | import Typography from '@mui/material/Typography'; 10 | import Card from '@mui/material/Card'; 11 | 12 | import CardContent from '@mui/material/CardContent'; 13 | 14 | import format from 'date-fns/format'; 15 | 16 | 17 | export function TransactionImportRecord(props) { 18 | const createdAt = format(new Date(props.record.created_at), "yyyy-MM-dd k:m O"); 19 | const maybeDuplicate = props.record.created_new ? "" : "(duplicate)"; 20 | const maybeEvent = props.record.event ? {props.record.event_type} : null; 21 | 22 | return 23 | expand_more} 25 | aria-controls="import-result-content" 26 | id="import-result-header" 27 | > 28 | {maybeEvent ? maybeEvent : "Transaction"} imported from {props.record.integration} at {createdAt} {maybeDuplicate} 29 | 30 | 31 |

    Raw data

    32 | 33 | {props.record.raw_record} 34 | 35 |
    36 |
    ; 37 | } 38 | 39 | TransactionImportRecord.propTypes = { 40 | record: PropTypes.shape({ 41 | created_at: PropTypes.string.isRequired, 42 | raw_record: PropTypes.string.isRequired, 43 | integration: PropTypes.string.isRequired, 44 | created_new: PropTypes.bool.isRequired, 45 | transaction_import: PropTypes.number.isRequired, 46 | event: PropTypes.number, 47 | event_type: PropTypes.string, 48 | }).isRequired, 49 | }; 50 | 51 | 52 | export function EventImportRecord(props) { 53 | const createdAt = format(new Date(props.record.created_at), "yyyy-MM-dd k:m O"); 54 | const maybeDuplicate = props.record.created_new ? "" : "(duplicate)"; 55 | const maybeTransaction = props.record.transaction ? (related transaction) : null; 56 | 57 | return 58 | expand_more} 60 | aria-controls="import-result-content" 61 | id="import-result-header" 62 | > 63 | {props.record.event_type} {maybeTransaction} imported from {props.record.integration} at {createdAt} {maybeDuplicate} 64 | 65 | 66 |

    Raw data

    67 | 68 | {props.record.raw_record} 69 | 70 |
    71 |
    ; 72 | } 73 | 74 | EventImportRecord.propTypes = { 75 | record: PropTypes.shape({ 76 | created_at: PropTypes.string.isRequired, 77 | raw_record: PropTypes.string.isRequired, 78 | integration: PropTypes.string.isRequired, 79 | created_new: PropTypes.bool.isRequired, 80 | transaction_import: PropTypes.number.isRequired, 81 | event: PropTypes.number, 82 | event_type: PropTypes.string, 83 | transaction: PropTypes.number, 84 | }).isRequired, 85 | }; 86 | 87 | 88 | export function TransactionImportRecordReferencingTransaction(props) { 89 | const maybeDuplicate = props.record.successful ? (props.record.created_new ? "(new)" : "(duplicate)") : ""; 90 | 91 | const maybeTransaction = props.record.transaction ? 92 | transaction : null; 93 | const maybeEvent = props.record.event ? {props.record.event_type} : null; 94 | const maybeIssue = props.record.issue_type ? "Issue type: " + props.record.issue_type : null; 95 | return
    96 | 97 | 98 | 99 |

    100 | {maybeEvent} {maybeTransaction} {maybeDuplicate} 101 | {maybeIssue} 102 | 103 |

    104 | {props.record.raw_issue ? 105 | 106 | {props.record.raw_issue} 107 | : null} 108 | 109 | {props.record.raw_record} 110 | 111 | 112 | 113 |
    114 |
    115 |
    ; 116 | } 117 | 118 | TransactionImportRecordReferencingTransaction.propTypes = { 119 | record: PropTypes.shape({ 120 | raw_record: PropTypes.string.isRequired, 121 | created_new: PropTypes.bool.isRequired, 122 | transaction: PropTypes.number, 123 | issue_type: PropTypes.string, 124 | raw_issue: PropTypes.string, 125 | successful: PropTypes.bool.isRequired, 126 | event: PropTypes.number, 127 | event_type: PropTypes.string, 128 | }).isRequired, 129 | }; --------------------------------------------------------------------------------