├── src ├── app │ ├── .htaccess │ ├── ModelloRedditiTemplates │ │ ├── Template2022Rl.php │ │ ├── Template2022Rm.php │ │ ├── Template2022Rt.php │ │ ├── Template2022Rw.php │ │ ├── Template2017Rl.php │ │ ├── Template2017Rm.php │ │ ├── Template2017Rt.php │ │ ├── Template2017Rw.php │ │ ├── Template2018Rl.php │ │ ├── Template2018Rm.php │ │ ├── Template2018Rw.php │ │ ├── Template2020Rl.php │ │ ├── Template2020Rm.php │ │ ├── Template2020Rt.php │ │ ├── Template2020Rw.php │ │ ├── Template2021Rl.php │ │ ├── Template2021Rm.php │ │ ├── Template2021Rt.php │ │ ├── Template2021Rw.php │ │ ├── Template2019Rl.php │ │ ├── Template2019Rw.php │ │ ├── Template2019Rt.php │ │ ├── Template2019Rm.php │ │ ├── Template2016Rm.php │ │ ├── Template2016Rl.php │ │ ├── Template2018Rt.php │ │ ├── Template2016Rw.php │ │ ├── Template2016Rt.php │ │ ├── Template.php │ │ └── TemplatesManager.php │ ├── Exceptions │ │ ├── BaseException.php │ │ ├── InvalidFileException.php │ │ ├── FileTooBigException.php │ │ ├── ActionInvalidException.php │ │ ├── InvalidYearException.php │ │ ├── NotFoundException.php │ │ ├── NegativeBalanceException.php │ │ ├── TooFewTransactionFields.php │ │ ├── InvalidTransactionException.php │ │ └── CannotFindPurchasesException.php │ ├── Models │ │ ├── ModelloRedditiRm.php │ │ ├── ModelloRedditiRl.php │ │ ├── ModelloRedditi.php │ │ ├── ModelloF24.php │ │ ├── ModelloRedditiRw.php │ │ ├── ModelloRedditiRt.php │ │ ├── PdfDocument.php │ │ ├── Transaction.php │ │ ├── TransactionsBag.php │ │ ├── CryptoInfoBag.php │ │ ├── EarningsBag.php │ │ ├── CryptoInfo.php │ │ ├── ReportWrapper.php │ │ └── Report.php │ ├── Utils │ │ ├── AesUtils.php │ │ ├── NumberUtils.php │ │ ├── DbUtils.php │ │ ├── VersionUtils.php │ │ ├── DateUtils.php │ │ └── CryptoInfoUtils.php │ └── Controllers │ │ ├── MainController.php │ │ └── WebAppController.php ├── tmp │ ├── .htaccess │ └── .gitignore ├── resources │ ├── .htaccess │ ├── pdf │ │ ├── F24.pdf │ │ ├── PF-2016.pdf │ │ ├── PF-2017.pdf │ │ ├── PF-2018.pdf │ │ ├── PF-2019.pdf │ │ ├── PF-2020.pdf │ │ ├── PF-2021.pdf │ │ └── PF-2022.pdf │ ├── dump.sql │ └── views │ │ └── report.html ├── get-version.sh ├── config.sample.php ├── composer.json ├── index.php ├── public │ ├── style.css │ └── script.js └── cron.php ├── .gitattributes ├── .gitignore ├── .github └── workflows │ └── core.yml ├── README.md └── LICENSE /src/app/.htaccess: -------------------------------------------------------------------------------- 1 | Deny from all 2 | -------------------------------------------------------------------------------- /src/tmp/.htaccess: -------------------------------------------------------------------------------- 1 | Deny from all 2 | -------------------------------------------------------------------------------- /src/resources/.htaccess: -------------------------------------------------------------------------------- 1 | Deny from all 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | screencast.gif export-ignore 2 | -------------------------------------------------------------------------------- /src/tmp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !.htaccess 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/config.php 2 | src/version.txt 3 | src/vendor 4 | -------------------------------------------------------------------------------- /src/get-version.sh: -------------------------------------------------------------------------------- 1 | git describe --tags > version.txt 2 | git log -1 --format=%cd --date=short >> version.txt 3 | -------------------------------------------------------------------------------- /src/resources/pdf/F24.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/F24.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2016.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2016.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2017.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2017.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2018.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2018.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2019.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2019.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2020.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2020.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2021.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2021.pdf -------------------------------------------------------------------------------- /src/resources/pdf/PF-2022.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cristianlivella/cryptax/HEAD/src/resources/pdf/PF-2022.pdf -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2022Rl.php: -------------------------------------------------------------------------------- 1 | 'CRYPTOHISTORY_TICKER' 13 | ]; 14 | 15 | const MODE = 'private'; 16 | 17 | date_default_timezone_set('Europe/Rome'); 18 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2019Rt.php: -------------------------------------------------------------------------------- 1 | setField('minusvalenze_anni_precedenti', 98.4, 103.8, true); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/Exceptions/InvalidFileException.php: -------------------------------------------------------------------------------- 1 | getShortName() . ': The uploaded file is invalid'; 13 | } 14 | 15 | public function toJson() { 16 | return [ 17 | 'exception' => $this->getShortName() 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/Exceptions/FileTooBigException.php: -------------------------------------------------------------------------------- 1 | getShortName() . ': The maximum upload file size is 1 MB.'; 13 | } 14 | 15 | public function toJson() { 16 | return [ 17 | 'exception' => $this->getShortName() 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2019Rm.php: -------------------------------------------------------------------------------- 1 | setField('ammontare_reddito', 96, 121, true); 15 | $this->setField('aliquota', 108, 121); 16 | $this->setField('imposta_dovuta', 159.5, 121, true); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2016Rm.php: -------------------------------------------------------------------------------- 1 | setField('tipo_reddito', 46, 121, false); 13 | $this->setField('ammontare_reddito', 111.5, 121, true); 14 | $this->setField('aliquota', 120, 121); 15 | $this->setField('imposta_dovuta', 164.5, 121, true); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/resources/dump.sql: -------------------------------------------------------------------------------- 1 | SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 2 | START TRANSACTION; 3 | SET time_zone = "+00:00"; 4 | 5 | CREATE TABLE `cache` ( 6 | `id` bigint(20) NOT NULL, 7 | `ticker` varchar(20) NOT NULL, 8 | `name` varchar(300) NOT NULL, 9 | `date` date NOT NULL, 10 | `quote` double NOT NULL, 11 | `expiration` int(11) NOT NULL, 12 | `found` tinyint(1) NOT NULL 13 | ) ENGINE=InnoDB DEFAULT CHARSET=latin1; 14 | 15 | ALTER TABLE `cache` 16 | ADD PRIMARY KEY (`id`), 17 | ADD UNIQUE KEY `crypto_id` (`ticker`,`date`); 18 | 19 | ALTER TABLE `cache` 20 | MODIFY `id` bigint(20) NOT NULL AUTO_INCREMENT; 21 | COMMIT; 22 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2016Rl.php: -------------------------------------------------------------------------------- 1 | setField('tipo_reddito', 127, 57.4); 13 | $this->setField('altri_redditi_di_capitale', 162.8, 57.4, true); 14 | $this->setField('altri_redditi_di_capitale_ritenute', 193, 57.4, true); 15 | $this->setField('totale', 162.8, 66, true); 16 | $this->setField('totale_ritenute', 193, 66, true); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/Exceptions/ActionInvalidException.php: -------------------------------------------------------------------------------- 1 | action = $action; 11 | 12 | parent::__construct(); 13 | } 14 | 15 | public function __toString() { 16 | return $this->getShortName() . ': Action ' . $this->action . ' is invalid '; 17 | } 18 | 19 | public function toJson() { 20 | return [ 21 | 'exception' => $this->getShortName(), 22 | 'action' => $this->action 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/Exceptions/InvalidYearException.php: -------------------------------------------------------------------------------- 1 | year = $year; 11 | 12 | parent::__construct('Invalid transaction'); 13 | } 14 | 15 | public function __toString() { 16 | return $this->getShortName() . ': Year ' . $this->year . ' is invalid '; 17 | } 18 | 19 | public function toJson() { 20 | return [ 21 | 'exception' => $this->getShortName(), 22 | 'year' => $this->year 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/Exceptions/NotFoundException.php: -------------------------------------------------------------------------------- 1 | entity = $entity; 11 | 12 | parent::__construct(); 13 | } 14 | 15 | public function __toString() { 16 | return $this->getShortName() . ': ' . ($this->entity ? ('(' . $this->entity . ') ') : '') . 'Not found.'; 17 | } 18 | 19 | public function toJson() { 20 | return [ 21 | 'exception' => $this->getShortName(), 22 | 'entity' => $this->entity 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/Models/ModelloRedditiRm.php: -------------------------------------------------------------------------------- 1 | setValue('tipo_reddito', 'G'); 13 | $template->setValue('ammontare_reddito', $info['rm']['interests']); 14 | $template->setValue('aliquota', $info['rm']['tax_rate']); 15 | $template->setValue('imposta_dovuta', $info['rm']['tax']); 16 | 17 | $template->writeOnPdf($pdf); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2018Rt.php: -------------------------------------------------------------------------------- 1 | = 0; $i--) { 14 | if (static::FISCAL_YEAR - $i < 2016) { 15 | // first report generated is for fiscal year 2016, skip previous years 16 | continue; 17 | } 18 | $this->setField('minusvalenze_anno_' . $i, 192 - $i * 27.8, 218, true); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/Models/ModelloRedditiRl.php: -------------------------------------------------------------------------------- 1 | setValue('tipo_reddito', '1'); 13 | $template->setValue('altri_redditi_di_capitale', $info['rl']['interests']); 14 | $template->setValue('altri_redditi_di_capitale_ritenute', '0'); 15 | $template->setValue('totale', $info['rl']['interests']); 16 | $template->setValue('totale_ritenute', '0'); 17 | 18 | $template->writeOnPdf($pdf); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cristianlivella/cryptax", 3 | "description": "Software for calculating cryptocurrency capital gains for tax purposes. Calculation with LIFO method, generation of pre-filled tax forms, made in Italy for Italy.", 4 | "type": "project", 5 | "license": "GPL-3.0-only", 6 | "authors": [ 7 | { 8 | "name": "Cristian Livella", 9 | "email": "cristian@cristianlivella.com" 10 | } 11 | ], 12 | "require": { 13 | "twig/twig": "^3.3", 14 | "setasign/fpdf": "^1.8", 15 | "setasign/fpdi": "^2.3", 16 | "phpoffice/phpspreadsheet": "^1.19", 17 | "spatie/once": "^2.2" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "CrypTax\\": "app/" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/Utils/AesUtils.php: -------------------------------------------------------------------------------- 1 | setField('codice_titolo_possesso', 46, 57.8); 13 | $this->setField('titolare_effettivo', 61, 57.8); 14 | $this->setField('codice_individuazione_bene', 76, 57.8); 15 | $this->setField('quota_possesso', 106, 57.8); 16 | $this->setField('criterio_determinazione_valore', 121, 57.8); 17 | $this->setField('valore_iniziale', 159, 57.8, true); 18 | $this->setField('valore_finale', 192.3, 57.8, true); 19 | $this->setField('quadri_aggiuntivi', 164, 82.6); 20 | $this->setField('solo_monitoraggio', 191, 82.6); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/Exceptions/NegativeBalanceException.php: -------------------------------------------------------------------------------- 1 | ticker = $ticker; 13 | $this->balance = $balance; 14 | $this->date = $date; 15 | } 16 | 17 | public function __toString() { 18 | return $this->getShortName() . ': Negative balance (' . $this->balance . ') for crypto ' . $this->ticker . ($this->date ? (' on ' . $this->date) : ''); 19 | } 20 | 21 | public function toJson() { 22 | return [ 23 | 'exception' => $this->getShortName(), 24 | 'ticker' => $this->ticker, 25 | 'balance' => $this->balance, 26 | 'date' => $this->date 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/Utils/NumberUtils.php: -------------------------------------------------------------------------------- 1 | $value) { 22 | if (is_array($value)) { 23 | $array[$key] = self::recursiveFormatNumbers($value, $digits, $roundOnly); 24 | } else { 25 | $array[$key] = $roundOnly ? round($value, $digits) : number_format($value, $digits, ',', '.'); 26 | } 27 | } 28 | 29 | return $array; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/Exceptions/TooFewTransactionFields.php: -------------------------------------------------------------------------------- 1 | transactionId = $transactionId; 13 | $this->fieldsCount = $fieldsCount; 14 | } 15 | 16 | public function __toString() { 17 | return $this->getShortName() . ': Transaction ' . $this->transactionId . ', ' . $this->fieldsCount . ' fields received, ' . self::EXPECTED_FIELDS . ' expected'; 18 | } 19 | 20 | public function toJson() { 21 | return [ 22 | 'exception' => $this->getShortName(), 23 | 'transaction_id' => $this->transactionId, 24 | 'fields' => $this->fieldsCount, 25 | 'expected' => self::EXPECTED_FIELDS 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/index.php: -------------------------------------------------------------------------------- 1 | toJson(), JSON_PRETTY_PRINT); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/Exceptions/InvalidTransactionException.php: -------------------------------------------------------------------------------- 1 | transactionId = $transactionId; 13 | $this->attribute = $attribute; 14 | $this->value = $value; 15 | 16 | parent::__construct('Invalid transaction'); 17 | } 18 | 19 | public function __toString() { 20 | return $this->getShortName() . ': Transaction ' . $this->transactionId . ', invalid ' . $this->attribute . ' (' . $this->value . ')'; 21 | } 22 | 23 | public function toJson() { 24 | return [ 25 | 'exception' => $this->getShortName(), 26 | 'transaction_id' => $this->transactionId, 27 | 'attribute' => $this->attribute, 28 | 'value' => $this->value 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/Exceptions/CannotFindPurchasesException.php: -------------------------------------------------------------------------------- 1 | transactionId = $transactionId; 13 | $this->foundAmount = $foundAmount; 14 | $this->totalAmount = $totalAmount; 15 | } 16 | 17 | public function __toString() { 18 | return $this->getShortName() . ': Transaction ' . $this->transactionId . ', ' . round($this->foundAmount, 8) . ' found on the total of ' . round($this->totalAmount, 8); 19 | } 20 | 21 | public function toJson() { 22 | return [ 23 | 'exception' => $this->getShortName(), 24 | 'transaction_id' => $this->transactionId, 25 | 'found_amount' => $this->foundAmount, 26 | 'total_amount' => $this->totalAmount 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/Models/ModelloRedditi.php: -------------------------------------------------------------------------------- 1 | pdf = new PdfDocument(); 15 | 16 | $this->fiscalYear = $info['fiscal_year']; 17 | 18 | if ($info['sections_required']['rl']) { 19 | ModelloRedditiRl::fill($this->pdf, $info, $this->fiscalYear); 20 | } 21 | 22 | if ($info['sections_required']['rw']) { 23 | ModelloRedditiRw::fill($this->pdf, $info, $this->fiscalYear); 24 | } 25 | 26 | if ($info['sections_required']['rt']) { 27 | ModelloRedditiRt::fill($this->pdf, $info, $this->fiscalYear); 28 | } 29 | 30 | if ($info['sections_required']['rm']) { 31 | ModelloRedditiRm::fill($this->pdf, $info, $this->fiscalYear); 32 | } 33 | } 34 | 35 | public function getPdf() { 36 | return $this->pdf->Output(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/public/style.css: -------------------------------------------------------------------------------- 1 | @page { 2 | size: auto; 3 | } 4 | 5 | body { 6 | margin: 8px; 7 | } 8 | 9 | th { 10 | vertical-align: middle !important; 11 | text-align: center; 12 | } 13 | 14 | .price { 15 | text-align: right; 16 | } 17 | 18 | .section { 19 | text-align: center; 20 | } 21 | 22 | .unit { 23 | width: 1px; 24 | } 25 | 26 | .column { 27 | width: 50%; 28 | float: left; 29 | margin-top: 8px; 30 | margin-bottom: 24px; 31 | } 32 | 33 | .selectors { 34 | flex-grow: 1; 35 | text-align: right; 36 | padding: 14px; 37 | } 38 | 39 | .document_selectors > * { 40 | margin-left: 8px; 41 | } 42 | 43 | a.selected { 44 | font-weight: bold; 45 | } 46 | 47 | @media print { 48 | body { 49 | margin: 0px; 50 | } 51 | 52 | .pagebreak { 53 | clear: both; 54 | page-break-after: always; 55 | } 56 | 57 | .rightColumn { 58 | width: 45% !important; 59 | } 60 | 61 | .leftColumn { 62 | width: 55% !important; 63 | } 64 | 65 | .selectors { 66 | display: none; 67 | } 68 | 69 | canvas { 70 | max-width: 100%; 71 | width: auto !important; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/Controllers/MainController.php: -------------------------------------------------------------------------------- 1 | getModelloRedditi($year, $compensateCapitalLosses); 28 | case self::ACTION_PDF_MODELLO_F24: 29 | echo $reportWrapper->getModelloF24($year, $compensateCapitalLosses); 30 | default: 31 | echo $reportWrapper->getReport($year); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/Utils/DbUtils.php: -------------------------------------------------------------------------------- 1 | connection = mysqli_connect(DB_HOST, DB_USER, DB_PSW, DB_NAME); 17 | 18 | if (!$this->connection) { 19 | throw new Exception('Cannot connect to database'); 20 | } 21 | 22 | $this->connection->query('SET NAMES utf8'); 23 | mb_internal_encoding('UTF-8'); 24 | 25 | $tableExists = $this->connection->query('SELECT 1 FROM cache LIMIT 1'); 26 | if (!$tableExists) { 27 | $this->connection->multi_query(file_get_contents(__DIR__ . '/../../resources/dump.sql')); 28 | while ($this->connection->next_result()); 29 | } 30 | } 31 | 32 | /** 33 | * Get the connection istance. 34 | * 35 | * @return MySQLi 36 | */ 37 | public static function getConnection() { 38 | if (self::$instance === null) { 39 | self::$instance = new self(); 40 | } 41 | 42 | return self::$instance->connection; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/Utils/VersionUtils.php: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /src/cron.php: -------------------------------------------------------------------------------- 1 | isDot()) { 17 | continue; 18 | } 19 | 20 | if ($fileInfo->isFile() && substr($fileInfo->getFilename(), 0, 1) !== '.' && time() - $fileInfo->getCTime() > 60 * 60 * 12) { 21 | unlink($fileInfo->getRealPath()); 22 | } 23 | } 24 | 25 | // update cached prices that expire in the next 60 minutes 26 | define('FAKE_CACHE_EXPIRATION', true); 27 | 28 | $expirationTime = time() + (60 * 60); 29 | $stmt = DbUtils::getConnection()->prepare('SELECT date, ticker FROM cache WHERE expiration > 0 AND expiration < ?'); 30 | $stmt->bind_param('i', $expirationTime); 31 | $stmt->execute(); 32 | $result = $stmt->get_result(); 33 | $stmt->close(); 34 | 35 | while ($resultArray = $result->fetch_assoc()) { 36 | CryptoInfoUtils::getCryptoPrice($resultArray['ticker'], $resultArray['date']); 37 | } 38 | 39 | // get most recent prices 40 | $result = DbUtils::getConnection()->query('SELECT DISTINCT(ticker) FROM cache WHERE 1'); 41 | 42 | while ($resultArray = $result->fetch_assoc()) { 43 | CryptoInfoUtils::getCryptoPrice($resultArray['ticker'], DateUtils::getToday()); 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/core.yml: -------------------------------------------------------------------------------- 1 | name: CrypTax core 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | release: 7 | types: [ published ] 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Get latest code 15 | uses: actions/checkout@v2.3.2 16 | with: 17 | fetch-depth: 0 18 | - name: Get changed files 19 | id: changed-files 20 | uses: tj-actions/changed-files@v5.1 21 | - name: Turnstyle 22 | uses: softprops/turnstyle@v1 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | - name: Set UPDATE_COMPOSER 26 | run: echo "UPDATE_COMPOSER=${{ contains(steps.changed-files.outputs.modified_files, 'composer.json') || contains(steps.changed-files.outputs.modified_files, 'composer.lock') }}" >> $GITHUB_ENV 27 | - name: Build composer 28 | if: ${{ env.UPDATE_COMPOSER == 'true' }} 29 | uses: "ramsey/composer-install@v1" 30 | with: 31 | composer-options: "--ignore-platform-reqs --working-dir=src/ --no-interaction" 32 | - name: Save version 33 | run: ./get-version.sh 34 | working-directory: src/ 35 | - name: FTP publish 36 | uses: sebastianpopp/ftp-action@releases/v2 37 | with: 38 | host: ${{ secrets.FTP_HOST }} 39 | user: ${{ secrets.FTP_USERNAME }} 40 | password: ${{ secrets.FTP_PASSWORD }} 41 | remoteDir: ${{ secrets.FTP_DIR }} 42 | localDir: src/ 43 | options: "-v --delete -x ^config.php -x ^tmp/ ${{ (env.UPDATE_COMPOSER == 'false') && '-x ^vendor/' || '' }}" 44 | -------------------------------------------------------------------------------- /src/app/Models/ModelloF24.php: -------------------------------------------------------------------------------- 1 | pdf = new PdfDocument(); 16 | 17 | $this->pdf->setSourceFile(dirname(__FILE__) . '/../../resources/pdf/F24.pdf'); 18 | $templateId = $this->pdf->importPage(1); 19 | $this->pdf->addPage(); 20 | $this->pdf->useTemplate($templateId); 21 | 22 | $this->pdf->addHeaderFooter($fiscalYear, false); 23 | 24 | $this->pdf->SetTextColor(0, 0, 0); 25 | $this->pdf->SetFont('Courier', 'B', 10); 26 | $this->pdf->writeXY(80, 10, 'Scadenza del 30-06-' . ($fiscalYear + 1)); 27 | 28 | $totalTaxes = 0; 29 | 30 | foreach ($taxes AS $index => $tax) { 31 | $this->pdf->writeRTL(69, 87.3 + $index * 4.3, $tax['code']); 32 | $this->pdf->writeRTL(89, 87.3 + $index * 4.3, '0101'); 33 | $this->pdf->writeRTL(107, 87.3 + $index * 4.3, $fiscalYear); 34 | $this->pdf->writeRTL(140.2, 87.3 + $index * 4.3, number_format($tax['amount'], 2, ' ', '')); 35 | 36 | $totalTaxes += $tax['amount']; 37 | } 38 | 39 | $this->pdf->writeRTL(201, 112.5, number_format($totalTaxes, 2, ' ', '')); 40 | $this->pdf->writeRTL(201, 252.5, number_format($totalTaxes, 2, ' ', '')); 41 | } 42 | 43 | public function getPdf() { 44 | return $this->pdf->Output(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/Models/ModelloRedditiRw.php: -------------------------------------------------------------------------------- 1 | setValue('codice_titolo_possesso', '1'); 13 | $template->setValue('titolare_effettivo', '2'); 14 | $template->setValue('codice_individuazione_bene', '14'); 15 | $template->setValue('quota_possesso', '100'); 16 | $template->setValue('criterio_determinazione_valore', '1'); 17 | $template->setValue('valore_iniziale', round($info['rw']['initial_value'])); 18 | $template->setValue('valore_finale', round($info['rw']['final_value'])); 19 | 20 | $requiredSections = 0; 21 | foreach ($info['sections_required'] AS $section => $required) { 22 | if ($section !== 'rw' && $required) { 23 | $requiredSections++; 24 | } 25 | } 26 | 27 | if ($requiredSections > 1) { 28 | $template->setValue('quadri_aggiuntivi', '4'); 29 | } elseif ($info['sections_required']['rl']) { 30 | $template->setValue('quadri_aggiuntivi', '1'); 31 | } elseif ($info['sections_required']['rm']) { 32 | $template->setValue('quadri_aggiuntivi', '2'); 33 | } elseif ($info['sections_required']['rt']) { 34 | $template->setValue('quadri_aggiuntivi', '3'); 35 | } else { 36 | $template->setValue('quadri_aggiuntivi', '5'); 37 | } 38 | 39 | $template->setValue('solo_monitoraggio', 'X'); 40 | 41 | $template->writeOnPdf($pdf); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template2016Rt.php: -------------------------------------------------------------------------------- 1 | setField('totale_corrispettivi', 192, 91.2, true); 14 | 15 | // RT22 - Totale dei costi o dei valori di acquisto 16 | $this->setField('totale_costi_acquisto', 192, 95.4, true); 17 | 18 | // RT23.1 - Minusvalenze 19 | $this->setField('minusvalenze', 118.4, 99.6, true); 20 | 21 | // RT23.3 - Plusvalenze 22 | $this->setField('plusvalenze', 192, 99.6, true); 23 | 24 | // RT24.1 - Eccedenza minusvalenze anni precedenti 25 | $this->setField('minusvalenze_anni_precedenti', 129, 103.8, true); 26 | 27 | // RT24.4 - Eccedenza minusvalenze 28 | $this->setField('eccedenze_minusvalenze', 192, 103.8, true); 29 | 30 | // RT26 - Differenza 31 | $this->setField('differenza_plus_minus', 192, 112.2, true); 32 | 33 | // RT27 - Imposta sostitutiva 34 | $this->setField('imposta_sostitutiva', 192, 116.4, true); 35 | 36 | // RT29 - Imposta sostitutiva dovuta 37 | $this->setField('imposta_sostitutiva_dovuta', 192, 124.8, true); 38 | 39 | // RT93 - Minusvalenze non compensate nell'anno 40 | for ($i = 4; $i >= 0; $i--) { 41 | if (static::FISCAL_YEAR - $i < 2016) { 42 | // first report generated is for fiscal year 2016, skip previous years 43 | continue; 44 | } 45 | $this->setField('minusvalenze_anno_' . $i, 192 - $i * 27.8, 231, true); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/Models/ModelloRedditiRt.php: -------------------------------------------------------------------------------- 1 | setValue('totale_corrispettivi', $info['rt']['total_incomes']); 13 | $template->setValue('totale_costi_acquisto', $info['rt']['total_costs']); 14 | $template->setValue('minusvalenze', $info['rt']['capital_losses']); 15 | $template->setValue('plusvalenze', $info['rt']['capital_gains']); 16 | 17 | if ($info['rt']['compensate_capital_losses']) { 18 | $template->setValue('minusvalenze_anni_precedenti', $info['rt']['capital_losses_previous_years']); 19 | $template->setValue('eccedenze_minusvalenze', $info['rt']['capital_losses_previous_years']); 20 | $template->setValue('differenza_plus_minus', $info['rt']['capital_gains_compensated']); 21 | $template->setValue('imposta_sostitutiva', $info['rt']['capital_gains_compensated_tax']); 22 | $template->setValue('imposta_sostitutiva_dovuta', $info['rt']['capital_gains_compensated_tax']); 23 | 24 | for ($i = 4; $i >= 0; $i--) { 25 | $template->setValue('minusvalenze_anno_' . $i, $info['rt']['remaining_capital_losses'][$fiscalYear - $i] ?? 0); 26 | } 27 | } else { 28 | $template->setValue('differenza_plus_minus', $info['rt']['capital_gains']); 29 | $template->setValue('imposta_sostitutiva', $info['rt']['capital_gains_tax']); 30 | $template->setValue('imposta_sostitutiva_dovuta', $info['rt']['capital_gains_tax']); 31 | } 32 | 33 | $template->writeOnPdf($pdf); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/Models/PdfDocument.php: -------------------------------------------------------------------------------- 1 | SetAutoPageBreak(true, 0); 15 | $this->SetRightMargin(0); 16 | } 17 | 18 | public function writeXY($x, $y, $text) { 19 | $this->SetXY($x, $y); 20 | $this->Write(0, $text); 21 | } 22 | 23 | public function writeRTL($x, $y, $text) { 24 | $this->writeXY($x - strlen($text) * 2.1, $y, $text); 25 | } 26 | 27 | public function addHeaderFooter($fiscalYear, $pfFirstPage = true) { 28 | if ($pfFirstPage) { 29 | /* 30 | $this->SetTextColor(0, 0, 0); 31 | $this->SetFont('Helvetica', 'B', 10); 32 | $this->writeXY(150, 7, 'PERIODO D\'IMPOSTA ' . $fiscalYear); 33 | 34 | $this->SetTextColor(0, 66, 112); 35 | $this->SetFont('Helvetica', 'B', 14); 36 | $this->writeXY(43, 32, $fiscalYear + 1); 37 | */ 38 | 39 | $this->SetTextColor(0, 0, 0); 40 | $this->SetFont('Courier', '', 10); 41 | $this->writeXY(160, 15, 'Facsimile CrypTax'); 42 | } 43 | 44 | $this->SetFont('Courier', '', 9); 45 | $this->writeXY(10, 264, 'Generato da CrypTax - ' . VersionUtils::getVersion() . ' - github.com/cristianlivella/cryptax'); 46 | 47 | $this->SetFont('Courier', '', 8); 48 | $this->writeXY(10, 268, 'Modello generato in data ' . date('d/m/Y') . ' alle ore ' . date('H:i:s') . '.'); 49 | $this->writeXY(10, 276, 'ATTENZIONE: CrypTax e Cristian Livella non si assumono nessuna responsabilita riguardo la correttezza'); 50 | $this->writeXY(10, 280, 'e la completezza dei dati riportati in questo modulo. Il software offre un aiuto nel calcolo'); 51 | $this->writeXY(10, 284, 'delle plusvalenze e nella compilazione dei modelli, ma e\' responsabilita\' del contribuente di verificarne'); 52 | $this->writeXY(10, 288, 'la correttezza e la compatibilita\' con la propria situazione finanziaria complessiva.'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/Template.php: -------------------------------------------------------------------------------- 1 | config[$name] = [ 12 | 'x' => $posX, 13 | 'y' => $posY, 14 | 'page' => $page, 15 | 'rtl' => $rtl 16 | ]; 17 | } 18 | 19 | protected function removeField($name) { 20 | unset($this->config[$name]); 21 | } 22 | 23 | public function setValue($name, $value) { 24 | if (!isset($this->config[$name])) { 25 | return; 26 | } 27 | 28 | $config = $this->config[$name]; 29 | 30 | $this->writeQueue[] = [ 31 | 'x' => $config['x'], 32 | 'y' => $config['y'], 33 | 'page' => $config['page'], 34 | 'rtl' => $config['rtl'], 35 | 'value' => $value 36 | ]; 37 | } 38 | 39 | public function writeOnPdf($pdf) { 40 | $pdf->setSourceFile(dirname(__FILE__) . '/../../resources/pdf/PF-' . static::FISCAL_YEAR . '.pdf'); 41 | 42 | $currentPage = 0; 43 | 44 | // TODO: order writeQueue by page number (however, currently only the first page of each section is filled) 45 | 46 | foreach ($this->writeQueue AS $item) { 47 | if ($item['page'] !== $currentPage) { 48 | $currentPage = $item['page']; 49 | 50 | $templateId = $pdf->importPage($item['page'] + static::FIRST_PAGE - 1); 51 | $pdf->addPage(); 52 | $pdf->useTemplate($templateId); 53 | $pdf->addHeaderFooter(static::FISCAL_YEAR, $currentPage === 1); 54 | 55 | $pdf->SetTextColor(0, 0, 0); 56 | $pdf->SetFont('Courier', 'B', 10); 57 | } 58 | 59 | if ($item['rtl']) { 60 | $pdf->writeRTL($item['x'], $item['y'], $item['value']); 61 | } else { 62 | $pdf->writeXY($item['x'], $item['y'], $item['value']); 63 | } 64 | } 65 | 66 | while ($currentPage !== (static::LAST_PAGE - static::FIRST_PAGE + 1)) { 67 | $currentPage++; 68 | $templateId = $pdf->importPage($currentPage + static::FIRST_PAGE - 1); 69 | $pdf->addPage(); 70 | $pdf->useTemplate($templateId); 71 | $pdf->addHeaderFooter(static::FISCAL_YEAR, $currentPage === 1); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/ModelloRedditiTemplates/TemplatesManager.php: -------------------------------------------------------------------------------- 1 | Template2016Rl::class, 15 | 2017 => Template2017Rl::class, 16 | 2018 => Template2018Rl::class, 17 | 2019 => Template2019Rl::class, 18 | 2020 => Template2020Rl::class, 19 | 2021 => Template2021Rl::class, 20 | 2022 => Template2022Rl::class, 21 | ]; 22 | 23 | const TEMPLATES_RM = [ 24 | 2016 => Template2016Rm::class, 25 | 2017 => Template2017Rm::class, 26 | 2018 => Template2018Rm::class, 27 | 2019 => Template2019Rm::class, 28 | 2020 => Template2020Rm::class, 29 | 2021 => Template2021Rm::class, 30 | 2022 => Template2022Rm::class, 31 | ]; 32 | 33 | const TEMPLATES_RT = [ 34 | 2016 => Template2016Rt::class, 35 | 2017 => Template2017Rt::class, 36 | 2018 => Template2018Rt::class, 37 | 2019 => Template2019Rt::class, 38 | 2020 => Template2020Rt::class, 39 | 2021 => Template2021Rt::class, 40 | 2022 => Template2022Rt::class, 41 | ]; 42 | 43 | const TEMPLATES_RW = [ 44 | 2016 => Template2016Rw::class, 45 | 2017 => Template2017Rw::class, 46 | 2018 => Template2018Rw::class, 47 | 2019 => Template2019Rw::class, 48 | 2020 => Template2020Rw::class, 49 | 2021 => Template2021Rw::class, 50 | 2022 => Template2022Rw::class, 51 | ]; 52 | 53 | public static function isTemplateAvailable($year) { 54 | return isset(self::TEMPLATES_RL[$year]) && isset(self::TEMPLATES_RM[$year]) && isset(self::TEMPLATES_RT[$year]) && isset(self::TEMPLATES_RW[$year]); 55 | } 56 | 57 | public static function getTemplate($year, $type) { 58 | if ($type === self::TYPE_RL) { 59 | return self::getTemplateFromArray(self::TEMPLATES_RL, $year); 60 | } elseif ($type === self::TYPE_RM) { 61 | return self::getTemplateFromArray(self::TEMPLATES_RM, $year); 62 | } elseif ($type === self::TYPE_RT) { 63 | return self::getTemplateFromArray(self::TEMPLATES_RT, $year); 64 | } elseif ($type === self::TYPE_RW) { 65 | return self::getTemplateFromArray(self::TEMPLATES_RW, $year); 66 | } 67 | } 68 | 69 | private static function getTemplateFromArray($array, $year) { 70 | if (isset($array[$year])) { 71 | $class = $array[$year]; 72 | $template = new $class(); 73 | 74 | return $template; 75 | } elseif (isset($_GET['force']) && count($array) > 0) { 76 | return self::getTemplateFromArray($array, array_keys($array)[count($array) - 1]); 77 | } else { 78 | throw new InvalidYearException($year); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CrypTax 2 | Software for calculating cryptocurrency capital gains for tax purposes. Calculation with LIFO method, developed with Italy in mind. 3 | 4 |  5 | *The screencast is made with random transactions. They are not my holdings.* 6 | 7 | ## ⚠️ Please read here before use 8 | - This is just the software I use for my tax reports. It's not perfect and it's not production ready, use it at your own risk. 9 | - At the moment in Italy there is no clear regulation regarding the declaration and taxation for cryptocurrencies and capital gains, so some of the logic used in this software could come from my interpretations. I take no responsibility about it. Always DYOR (Do Your Own Research). 10 | 11 | ## ℹ️ What this software does 12 | At the moment, the features of this software are quite limited, but they are the most inconvenient and time-consuming to do manually, at least in my case. 13 | 14 | In fact, it is just a script that takes in input a csv file with all your transactions, and elaborates a quite complete and printable report. In the future I plan to make it a more complete software, maybe even with a nice front-end. 15 | 16 | ## ❓ How to use it 17 | - Put the project folder in a web server with PHP; 18 | - create a copy of the file `config.sample.php` in `config.php`, and set inside it the credentials of the MySQL database (it will be used as a cache for cryptocurrency prices) and the location of the `transactions.csv` file; 19 | - visit `index.php` through a web browser. 20 | 21 | ## 📄 The transactions.csv file 22 | - The csv file must have 7 columns, separated by semicolon `;`. 23 | - The file must not have a header row. 24 | 25 | This is the description of the fields: 26 | 1. **transaction_date**: in dd/mm/yyyy format 27 | 2. **transaction type**: `purchase`, `sale` or `expense` (or the correspondents in Italian: `acquisto`, `vendita` or `spesa`) 28 | 3. **EUR value**: the value of the transaction in euros, including commissions 29 | 4. **cryptocurrency amount**: amount of cryptocurrency bought, sold or spent; without thousands separators, using the dot `.` as decimal separator 30 | 5. **cryptocurrency ticker**: usually a 3 characters string, like `BTC`, `ETH` or `BNB` 31 | 6. **exchange**: name of the exchange where the buy or sell has been done; this is only for the volume chart, you can leave it empty if you are not interested 32 | 7. **earning category**: for `purchase`/`acquisto` type transactions with price = 0, you can set the earning category; it affects some calculations in the final report 33 | 34 | ### Earning categories 35 | - `airdrop`: a capital gain is calculated equal to the value of the cryptocurrency on the day of the transaction 36 | - `interessi`/`interest`: are considered as *redditi di capitale*, taxed at a rate of 26% 37 | - `cashback`: the value at the day of the transaction is not taxed, but only the eventual capital gain at the time of the sale 38 | 39 | 40 | **please note**: these are the considerations this software does, but they may not be the correct ones! DYOR and/or consult an expert! 41 | -------------------------------------------------------------------------------- /src/public/script.js: -------------------------------------------------------------------------------- 1 | const beforePrint = () => { 2 | for (const id in Chart.instances) { 3 | Chart.instances[id].resize(); 4 | } 5 | } 6 | 7 | window.onbeforeprint = beforePrint; 8 | 9 | if (window.matchMedia) { 10 | const mediaQueryList = window.matchMedia('print'); 11 | mediaQueryList.addListener((mql) => { 12 | if (mql.matches) { 13 | beforePrint(); 14 | } 15 | }); 16 | } 17 | 18 | const generateColors = (num, paletteName = 'mpn65') => { 19 | const scheme = palette.listSchemes(paletteName)[0]; 20 | return scheme.apply(scheme, [num]).reverse().map(color => { 21 | return '#' + color; 22 | }) 23 | } 24 | 25 | const ctx1 = document.getElementById('chart1').getContext('2d'); 26 | 27 | const config1 = { 28 | type: 'line', 29 | data: { 30 | labels: daysList, 31 | datasets: [ 32 | { 33 | label: 'Controvalore con prezzi al giorno 01/01/' + fiscalYear, 34 | data: dailyTotalValuesLegal, 35 | borderColor: ['rgb(54, 162, 235)'], 36 | backgroundColor:'rgb(54, 162, 235)', 37 | fill: false, 38 | yAxisID: 'y-axis-1', 39 | }, 40 | { 41 | label: 'Controvalore con prezzi al giorno 31/12/' + fiscalYear, 42 | data: dailyTotalValuesEOY, 43 | borderColor: ['rgb(255, 159, 64)'], 44 | backgroundColor:'rgb(255, 159, 64)', 45 | fill: false, 46 | yAxisID: 'y-axis-1', 47 | }, 48 | { 49 | label: 'Controvalore reale', 50 | data: dailyTotalValuesReal, 51 | borderColor: ['rgb(255, 99, 132)',], 52 | backgroundColor:'rgb(255, 99, 132)', 53 | fill: false, 54 | yAxisID: 'y-axis-1', 55 | } 56 | ] 57 | }, 58 | options: { 59 | scales: { 60 | yAxes: [{ 61 | type: 'linear', 62 | display: true, 63 | position: 'left', 64 | id: 'y-axis-1', 65 | ticks: { 66 | beginAtZero: true 67 | }, 68 | scaleLabel: { 69 | display: true, 70 | labelString: 'euro' 71 | } 72 | }], 73 | }, 74 | responsive:true, 75 | maintainAspectRatio: false, 76 | title: { 77 | display: true, 78 | fontSize: 18, 79 | fontColor: '#212529', 80 | text: 'Controvalore in euro' 81 | }, 82 | tooltips: { 83 | callbacks: { 84 | label: function(tooltipItem, data) { 85 | const labels = ['01/01', '31/12', 'reale'] 86 | return labels[tooltipItem.datasetIndex] + ': €' + tooltipItem.value.toString().replace(".", ",").replace(/\B(?=(\d{3})+(?!\d))/g, "."); 87 | }, 88 | }, 89 | mode: 'index', 90 | intersect: false, 91 | position: 'nearest', 92 | }, 93 | elements: { 94 | point:{ 95 | radius: 0 96 | } 97 | } 98 | } 99 | }; 100 | 101 | const chart1 = new Chart(ctx1, config1); 102 | 103 | if (document.getElementById('chart2')) { 104 | const ctx2 = document.getElementById('chart2').getContext('2d'); 105 | 106 | const config2 = { 107 | type: 'doughnut', 108 | data: { 109 | labels: exchanges, 110 | datasets: [{ 111 | data: exchangeVolumes, 112 | borderColor: generateColors(exchanges.length).reverse(), 113 | backgroundColor: generateColors(exchanges.length).reverse(), 114 | fill: true, 115 | yAxisID: 'y-axis-1', 116 | } 117 | ] 118 | }, 119 | options: { 120 | scales: { 121 | yAxes: [], 122 | }, 123 | responsive:true, 124 | maintainAspectRatio: false, 125 | title: { 126 | display: true, 127 | fontSize: 18, 128 | fontColor: '#212529', 129 | text: 'Volumi exchange' 130 | }, 131 | tooltips: { 132 | callbacks: { 133 | label: function(tooltipItem, data) { 134 | return data.labels[tooltipItem.index] + ': €' + data.datasets[0].data[tooltipItem.index].toFixed(2).replace(".", ",").replace(/\B(?=(\d{3})+(?!\d))/g, "."); 135 | }, 136 | }, 137 | mode: 'index', 138 | intersect: false, 139 | position: 'nearest', 140 | }, 141 | elements: { 142 | point:{ 143 | radius: 0 144 | } 145 | } 146 | } 147 | }; 148 | 149 | const chart2 = new Chart(ctx2, config2); 150 | } 151 | -------------------------------------------------------------------------------- /src/app/Utils/DateUtils.php: -------------------------------------------------------------------------------- 1 | getTimestamp()); 97 | } 98 | 99 | /** 100 | * Return the number of days in a specific year. 101 | * 102 | * @param integer $year 103 | * @return integer 104 | */ 105 | public static function getNumberOfDaysInYear($year) { 106 | return intval(date('z', mktime(0, 0, 0, 12, 31, $year))) + 1; 107 | } 108 | 109 | /** 110 | * Deprecated. TODO: to remove. 111 | */ 112 | public static function old_getNumerOfDaysInYear($year) { 113 | return intval(date('z', mktime(0, 0, 0, 12, 31, $year))); 114 | } 115 | 116 | /** 117 | * Get the current year. 118 | * 119 | * @return integer 120 | */ 121 | public static function getCurrentYear() { 122 | return intval(date('Y')); 123 | } 124 | 125 | /** 126 | * Get the day of the week (0 sunday - 6 saturday) from a day of year (0-365) 127 | * 128 | * @param integer $day 129 | * @param integer $year 130 | * @return integer 131 | */ 132 | public static function getDayOfWeek($day, $year) { 133 | // get the day of the week (0 sunday - 6 saturday) based on day of year (0-365) 134 | return intval(date('w', DateTime::createFromFormat('Y z', $year . ' ' . $day)->getTimestamp())); 135 | } 136 | 137 | /** 138 | * Get the list of day in a specific year, in a specific format. 139 | * 140 | * @param integer $year 141 | * @param integer $format 142 | * @return string[] 143 | */ 144 | public static function getListDaysInYear($year, $format = 'd/m') { 145 | $daysList = []; 146 | $daysInYear = self::getNumberOfDaysInYear($year); 147 | 148 | for ($i = 0; $i < $daysInYear; $i++) { 149 | $daysList[] = date('d/m', DateTime::createFromFormat('Y z', $year . ' ' . $i)->getTimestamp()); 150 | } 151 | 152 | return $daysList; 153 | } 154 | 155 | /** 156 | * Get the holidays in a specific year. 157 | * 158 | * @param integer $year 159 | * @return integer[] 160 | */ 161 | public static function getHolidays($year) { 162 | $holidays = [ 163 | [1, 1], // Capodanno 164 | [6, 1], // Epifania 165 | [5, 4], // Anniversario della Liberazione 166 | [1, 5], // Festa del Lavoro 167 | [2, 6], // Festa della Repubblica 168 | [15, 8], // Assunzione / Ferragosto 169 | [1, 11], // Tutti i santi 170 | [8, 12], // Immacolata concezione 171 | [25, 12], // Natale 172 | [26, 12], // Santo Stefano 173 | ]; 174 | 175 | $easter = easter_date($year); 176 | $holidays[] = explode('-', date('d-m', $easter)); // Pasqua 177 | $holidays[] = explode('-', date('d-m', $easter + (60 * 60 * 24))); // Pasquetta 178 | 179 | $holidayDays = []; 180 | 181 | foreach ($holidays AS $holiday) { 182 | $holidayDays[] = intval(date('z', mktime(0, 0, 0, $holiday[1], $holiday[0], $year))); 183 | } 184 | 185 | return $holidayDays; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/app/Models/Transaction.php: -------------------------------------------------------------------------------- 1 | 'acquisto', 21 | self::SALE => 'vendita', 22 | self::EXPENSE => 'spesa' 23 | ]; 24 | 25 | const TYPES_ALT = [ 26 | self::PURCHASE => 'buy', 27 | self::SALE => 'sell' 28 | ]; 29 | 30 | const CAPITAL_GAINS = 'capital_gains'; 31 | const AIRDROP = 'airdrop'; 32 | const INTEREST = 'interest'; 33 | const CASHBACK = 'cashback'; 34 | 35 | const EARNING_CATEGORIES = [self::AIRDROP, self::INTEREST, self::CASHBACK]; 36 | 37 | const EARNING_CATEGORIES_IT = [ 38 | self::CAPITAL_GAINS => 'plusvalenze', 39 | self::INTEREST => 'interessi' 40 | ]; 41 | 42 | public $id; 43 | public $date; 44 | public $type; 45 | public $ticker; 46 | public $amount; 47 | public $value; 48 | public $exchange; 49 | public $earningCategory; 50 | 51 | public $timestamp; 52 | 53 | public $used = 0.0; 54 | protected $purchases = []; 55 | 56 | public function __construct($id, $rawTx) { 57 | $this->id = $id; 58 | 59 | if (count($rawTx) < 5) { 60 | throw new TooFewTransactionFields($this->id, count($rawTx)); 61 | } 62 | 63 | $this->setDate($rawTx[0]); 64 | $this->setType($rawTx[1]); 65 | $this->setTicker($rawTx[4]); 66 | $this->setAmount($rawTx[3]); 67 | $this->setValue($rawTx[2]); 68 | $this->setExchange($rawTx[5] ?? ''); 69 | $this->setEarningCategory($rawTx[6] ?? ''); 70 | } 71 | 72 | public function incrementUsed($amount) { 73 | $this->used += $amount; 74 | } 75 | 76 | public function associatePurchase($transaction, $amount) { 77 | $this->purchases[] = [ 78 | 'transaction' => $transaction, 79 | 'amount' => $amount 80 | ]; 81 | } 82 | 83 | public function getCapitalGain() { 84 | if ($this->type === self::PURCHASE) { 85 | return 0.0; 86 | } 87 | 88 | return $this->value - $this->getRelativePurchaseCost(); 89 | } 90 | 91 | public function getRelativePurchases() { 92 | return $this->purchases; 93 | } 94 | 95 | public function getRelativePurchaseCost() { 96 | $totalCost = 0.0; 97 | 98 | foreach ($this->purchases AS $purchase) { 99 | $totalCost += $purchase['transaction']->value / $purchase['transaction']->amount * $purchase['amount']; 100 | } 101 | 102 | return $totalCost; 103 | } 104 | 105 | private function setDate($date) { 106 | $this->date = DateUtils::getDateFromItFormat($date); 107 | $this->timestamp = strtotime($this->date); 108 | 109 | if ($this->date === '1970-01-01' || $this->date > DateUtils::getToday()) { 110 | throw new InvalidTransactionException($this->id, 'date', $date); 111 | } 112 | } 113 | 114 | private function setType($type) { 115 | $this->type = strtolower(trim($type)); 116 | 117 | if (in_array($this->type, self::TYPES_IT)) { 118 | $this->type = array_search($this->type, self::TYPES_IT); 119 | } 120 | 121 | if (in_array($this->type, self::TYPES_ALT)) { 122 | $this->type = array_search($this->type, self::TYPES_ALT); 123 | } 124 | 125 | if (!in_array($this->type, self::TYPES)) { 126 | throw new InvalidTransactionException($this->id, 'type', $type); 127 | } 128 | } 129 | 130 | private function setTicker($ticker) { 131 | $ticker = preg_replace('/\([^)]+\)/', '', $ticker); 132 | $this->ticker = CryptoInfoUtils::getCryptoTicker(trim($ticker)); 133 | 134 | if ($this->ticker === '') { 135 | throw new InvalidTransactionException($this->id, 'ticker', $ticker); 136 | } 137 | } 138 | 139 | private function setAmount($amount) { 140 | $amount = str_replace(',', '.', $amount); 141 | 142 | if (is_numeric($amount)) { 143 | $this->amount = floatval($amount); 144 | } else { 145 | throw new InvalidTransactionException($this->id, 'amount', $value); 146 | } 147 | } 148 | 149 | private function setValue($value) { 150 | $value = str_replace(',', '.', $value); 151 | $value = str_replace(['€', '$', ' '], '', $value); 152 | 153 | if (is_numeric($value) && floatval($value) > 0) { 154 | $this->value = floatval($value); 155 | } elseif ((is_numeric($value) && floatval($value) === 0.0 ) || trim($value) === '') { 156 | $this->value = CryptoInfoUtils::getCryptoPrice($this->ticker, $this->date) * $this->amount; 157 | } else { 158 | throw new InvalidTransactionException($this->id, 'value', $value); 159 | } 160 | } 161 | 162 | private function setExchange($exchange) { 163 | $this->exchange = trim($exchange); 164 | } 165 | 166 | private function setEarningCategory($category) { 167 | $this->earningCategory = strtolower(trim($category)); 168 | 169 | if ($this->earningCategory === '') { 170 | $this->earningCategory = null; 171 | return; 172 | } 173 | 174 | if (in_array($this->earningCategory, self::EARNING_CATEGORIES_IT)) { 175 | $this->earningCategory = array_search($this->earningCategory, self::EARNING_CATEGORIES_IT); 176 | } 177 | 178 | if (!in_array($this->earningCategory, self::EARNING_CATEGORIES)) { 179 | throw new InvalidTransactionException($this->id, 'earning_category', $category); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/app/Models/TransactionsBag.php: -------------------------------------------------------------------------------- 1 | getSheet(0)->toArray(); 45 | 46 | // save the array in the JSON cache file 47 | file_put_contents($transactionsJsonFile, AesUtils::encrypt(json_encode($rawTransactions), $transactionsJsonKey)); 48 | } catch (\Exception $e) { 49 | throw new InvalidFileException(); 50 | } finally { 51 | unlink($filePath); 52 | } 53 | } 54 | 55 | $dateFormat1 = '/[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}/'; // dd-mm-YYYY 56 | $dateFormat2 = '/[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}/'; // YYYY-mm-dd 57 | 58 | $firstTxDate = $rawTransactions[0][0] ?? false; 59 | if ($firstTxDate !== false && !preg_match($dateFormat1, $firstTxDate) && !preg_match($dateFormat2, $firstTxDate)) { 60 | // if the date of the first row is invalid, assume it's a header row and ignore it 61 | unset($rawTransactions[0]); 62 | } 63 | 64 | // parse the transactions 65 | $rawTransactions = array_values($rawTransactions); 66 | foreach ($rawTransactions AS $id => $rawTx) { 67 | $this->transactions[$id + 1] = new Transaction($id + 1, $rawTx); 68 | } 69 | 70 | // sort the transactions 71 | uasort($this->transactions, function ($a, $b) { 72 | if ($a->date === $b->date) { 73 | return $a->id - $b->id; 74 | } 75 | 76 | return $a->timestamp - $b->timestamp; 77 | }); 78 | 79 | foreach ($this->transactions AS $transaction) { 80 | if ($transaction->type === Transaction::PURCHASE) { 81 | $this->addCryptoPurchase($transaction); 82 | } elseif ($transaction->type === Transaction::SALE || $transaction->type === Transaction::EXPENSE) { 83 | $this->findRelativePurchases($transaction); 84 | } 85 | } 86 | } 87 | 88 | public function getFirstTransaction() { 89 | if (count($this->transactions) === 0) { 90 | return null; 91 | } 92 | 93 | return $this->transactions[array_keys($this->transactions)[0]]; 94 | } 95 | 96 | public function getLastTransaction() { 97 | if (count($this->transactions) === 0) { 98 | return null; 99 | } 100 | 101 | return $this->transactions[array_keys($this->transactions)[count(array_keys($this->transactions)) - 1]]; 102 | } 103 | 104 | private function addCryptoPurchase($transaction) { 105 | $id = $transaction->id; 106 | $ticker = $transaction->ticker; 107 | 108 | if (!isset($this->cryptoPurchases[$ticker])) { 109 | $this->cryptoPurchases[$ticker] = []; 110 | } 111 | 112 | $this->cryptoPurchases[$ticker][] = $transaction; 113 | } 114 | 115 | private function findRelativePurchases($transaction) { 116 | if (!isset($this->cryptoPurchases[$transaction->ticker])) { 117 | throw new CannotFindPurchasesException($transaction->id, 0, $transaction->amount); 118 | } 119 | 120 | $i = count($this->cryptoPurchases[$transaction->ticker]); 121 | $purchaseAmountRemaining = $transaction->amount; 122 | 123 | while ($purchaseAmountRemaining > 0 && $i-- > 0) { 124 | $purchaseTransaction = $this->cryptoPurchases[$transaction->ticker][$i]; 125 | $partialUse = min($purchaseAmountRemaining, $purchaseTransaction->amount - $purchaseTransaction->used); 126 | 127 | if ($partialUse > 0) { 128 | $purchaseTransaction->incrementUsed($partialUse); 129 | $transaction->associatePurchase($purchaseTransaction, $partialUse); 130 | $purchaseAmountRemaining -= $partialUse; 131 | } 132 | 133 | if ($purchaseTransaction->amount === $purchaseTransaction->used) { 134 | unset($this->cryptoPurchases[$transaction->ticker][$i]); 135 | } 136 | } 137 | 138 | $this->cryptoPurchases[$transaction->ticker] = array_values($this->cryptoPurchases[$transaction->ticker]); 139 | 140 | if ($purchaseAmountRemaining > pow(10, -12)) { 141 | throw new CannotFindPurchasesException($transaction->id, $transaction->amount - $purchaseAmountRemaining, $transaction->amount); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/app/Models/CryptoInfoBag.php: -------------------------------------------------------------------------------- 1 | fiscalYear = $fiscalYear; 47 | 48 | foreach ($transactions AS $transaction) { 49 | if (!isset($this->cryptoInfo[$transaction->ticker])) { 50 | $this->cryptoInfo[$transaction->ticker] = new CryptoInfo($transaction->ticker, $this->fiscalYear); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Snapshot the balances at the beginning of the year, if not already done. 57 | * 58 | * @return void 59 | */ 60 | public function saveStartOfYearSnapshot() { 61 | if ($this->startOfYearDataInitialized) { 62 | return; 63 | } 64 | 65 | foreach ($this->cryptoInfo AS $cryptocurrency) { 66 | $cryptocurrency->saveBalanceStartOfYear(); 67 | } 68 | 69 | $this->startOfYearDataInitialized = true; 70 | } 71 | 72 | /** 73 | * Set the daily balances to the current ones until reach the specific day of year. 74 | * 75 | * @param integer $day day of the year, 0-365 76 | * @return void 77 | */ 78 | public function setBalancesUntilDay($day) { 79 | if ($this->currentDayOfYear < $day) { 80 | foreach ($this->cryptoInfo AS $cryptocurrency) { 81 | $cryptocurrency->setBalancesUntilDay($day); 82 | } 83 | 84 | $this->currentDayOfYear = $day; 85 | } 86 | } 87 | 88 | /** 89 | * Increment the balance of a given cryptocurrency. 90 | * 91 | * @param string $ticker 92 | * @param float $amount 93 | * @param Transaction $transaction 94 | * @return void 95 | */ 96 | public function incrementCryptoBalance($ticker, $amount, $transaction = null) { 97 | $this->cryptoInfo[$ticker]->incrementBalance($amount, $transaction); 98 | } 99 | 100 | /** 101 | * Decrement the balance of a given cryptocurrency. 102 | * 103 | * @param string $ticker 104 | * @param float $amount 105 | * @param Transaction $transaction 106 | * @return void 107 | */ 108 | public function decrementCryptoBalance($ticker, $amount, $transaction = null) { 109 | $this->incrementCryptoBalance($ticker, $amount * -1); 110 | } 111 | 112 | /** 113 | * Sort the cryptocurrencies list by their decreasing average value. 114 | * 115 | * @return void 116 | */ 117 | public function sortByAverageValue() { 118 | usort($this->cryptoInfo, function ($a, $b) { 119 | $averageA = $a->getAverageValue(); 120 | $averageB = $b->getAverageValue(); 121 | 122 | if ($averageA > $averageB) { 123 | return -1; 124 | } elseif ($averageA < $averageB) { 125 | return 1; 126 | } else { 127 | return 0; 128 | } 129 | }); 130 | } 131 | 132 | /** 133 | * Get the total daily values using the prices at the beginning of the fiscal year. 134 | * 135 | * @return float[] 136 | */ 137 | public function getDailyValuesStartOfYear() { 138 | return $this->getDailyValues($this->fiscalYear . '-01-01'); 139 | } 140 | 141 | /** 142 | * Get the total daily values using the prices at the end of the fiscal year. 143 | * 144 | * @return float[] 145 | */ 146 | public function getDailyValuesEndOfYear() { 147 | return $this->getDailyValues($this->fiscalYear . '-12-31'); 148 | } 149 | 150 | /** 151 | * Get the total daily values using the price at the specified date. 152 | * If priceDate is null, real daily prices are used. 153 | * 154 | * @param string $priceDate 155 | * @return float[] 156 | */ 157 | public function getDailyValues($priceDate = null) { 158 | $dailyValues = array_fill(0, DateUtils::old_getNumerOfDaysInYear($this->fiscalYear) + 1, 0); 159 | 160 | foreach ($this->cryptoInfo AS $cryptocurrency) { 161 | foreach ($cryptocurrency->getDailyValues($priceDate) AS $day => $value) { 162 | $dailyValues[$day] += $value; 163 | } 164 | } 165 | 166 | return $dailyValues; 167 | } 168 | 169 | /** 170 | * Get the info used for the report rendering. 171 | * 172 | * @return array 173 | */ 174 | public function getInfoForRender() { 175 | return array_filter(array_map(function ($cryptocurrency) { 176 | if ($cryptocurrency->getAverageValue() === 0.0) { 177 | return null; 178 | } 179 | 180 | return $cryptocurrency->getInfoForRender(); 181 | }, $this->cryptoInfo), function($crypto) { 182 | return $crypto !== null; 183 | }); 184 | } 185 | 186 | /** 187 | * Get the sum of the value related data. 188 | * 189 | * @return array 190 | */ 191 | public function getTotalValues() { 192 | $totals = [ 193 | 'value_start_of_year' => 0.0, 194 | 'value_end_of_year' => 0.0, 195 | 'average_value' => 0.0, 196 | 'max_value' => 0.0 197 | ]; 198 | 199 | foreach ($this->cryptoInfo AS $cryptocurrency) { 200 | $totals['value_start_of_year'] += $cryptocurrency->getValueStartOfYear(); 201 | $totals['value_end_of_year'] += $cryptocurrency->getValueEndOfYear(); 202 | $totals['average_value'] += $cryptocurrency->getAverageValue(); 203 | $totals['max_value'] += $cryptocurrency->getMaxValue(); 204 | } 205 | 206 | return $totals; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/app/Controllers/WebAppController.php: -------------------------------------------------------------------------------- 1 | getReport($year); 62 | } 63 | 64 | public static function printModelloRedditi() { 65 | $year = self::getSelectedYear(); 66 | 67 | $settings = self::getSelectedReportSettings(); 68 | $compensateCapitalLosses = $settings['compensate_losses'] ?? true; 69 | $exchangeSettings = $settings['exchanges'] ?? []; 70 | $finalValueMethod = $settings['rw_final_value_method'] ?? 'average_value'; 71 | $considerEarningsAndExpensesAsInvestment = $settings['consider_earnings_and_expenses_as_investment'] ?? true; 72 | 73 | $reportWrapper = new ReportWrapper(self::getSelectedReportContent(), $exchangeSettings, $considerEarningsAndExpensesAsInvestment); 74 | 75 | echo $reportWrapper->getModelloRedditi($year, $compensateCapitalLosses, $finalValueMethod); 76 | } 77 | 78 | public static function printModelloF24() { 79 | $year = self::getSelectedYear(); 80 | 81 | $settings = self::getSelectedReportSettings(); 82 | $compensateCapitalLosses = $settings['compensate_losses'] ?? true; 83 | $exchangeSettings = $settings['exchanges'] ?? []; 84 | 85 | $reportWrapper = new ReportWrapper(self::getSelectedReportContent(), $exchangeSettings); 86 | 87 | echo $reportWrapper->getModelloF24($year, $compensateCapitalLosses); 88 | } 89 | 90 | public static function upload() { 91 | $file = array_values($_FILES)[0] ?? null; 92 | 93 | if ($file === null) { 94 | throw new InvalidFileException(); 95 | } 96 | 97 | if ($file['size'] > 25 * 1024 * 1024) { 98 | throw new FileTooBigException(); 99 | } 100 | 101 | $reportId = bin2hex(random_bytes(16)); 102 | $key = AesUtils::generateKey(); 103 | $filePath = dirname(__FILE__) . '/../../tmp/' . $reportId; 104 | 105 | file_put_contents($filePath, AesUtils::encrypt(file_get_contents($file['tmp_name']), $key)); 106 | unlink($file['tmp_name']); 107 | 108 | $reportWrapper = new ReportWrapper(AesUtils::decrypt(file_get_contents($filePath), $key)); 109 | 110 | self::setCookie('KEY-' . $reportId, $key); 111 | 112 | header('Content-type: application/json'); 113 | echo json_encode(['report_id' => $reportId] + $reportWrapper->getSummary(true)); 114 | } 115 | 116 | public static function getInfo() { 117 | $reportId = self::getSelectedReportId(); 118 | $settings = self::getSelectedReportSettings(); 119 | 120 | $exchangeSettings = $settings['exchanges'] ?? []; 121 | $considerEarningsAndExpensesAsInvestment = $settings['consider_earnings_and_expenses_as_investment'] ?? true; 122 | 123 | $reportWrapper = new ReportWrapper(self::getSelectedReportContent(), $exchangeSettings, $considerEarningsAndExpensesAsInvestment); 124 | 125 | header('Content-type: application/json'); 126 | echo json_encode(['report_id' => $reportId] + $reportWrapper->getSummary(true)); 127 | } 128 | 129 | public static function setSettings() { 130 | $reportId = self::getSelectedReportId(); 131 | $settings = self::getSelectedReportSettings(); 132 | 133 | if ($settings === null) { 134 | $settings = []; 135 | } 136 | 137 | if (isset($_POST['exchanges'])) { 138 | $settings['exchanges'] = json_decode($_POST['exchanges'], true); 139 | } 140 | 141 | if (isset($_POST['compensate_losses'])) { 142 | $settings['compensate_losses'] = filter_var($_POST['compensate_losses'] ?? true, FILTER_VALIDATE_BOOLEAN); 143 | } 144 | 145 | if (isset($_POST['rw_final_value_method'])) { 146 | $settings['rw_final_value_method'] = $_POST['rw_final_value_method']; 147 | } 148 | 149 | if (isset($_POST['consider_earnings_and_expenses_as_investment'])) { 150 | $settings['consider_earnings_and_expenses_as_investment'] = $_POST['consider_earnings_and_expenses_as_investment']; 151 | } 152 | 153 | self::setCookie('SETTINGS-' . $reportId, base64_encode(json_encode($settings))); 154 | } 155 | 156 | private static function getSelectedYear() { 157 | return isset($_GET['year']) ? intval($_GET['year']) : DateUtils::getCurrentYear(); 158 | } 159 | 160 | private static function getSelectedReportContent() { 161 | $reportId = self::getSelectedReportId(); 162 | $filePath = dirname(__FILE__) . '/../../tmp/' . basename($reportId); 163 | 164 | if (strlen($reportId) !== 32 || !file_exists($filePath)) { 165 | throw new NotFoundException('report'); 166 | } 167 | 168 | $fileContent = AesUtils::decrypt(file_get_contents($filePath), $_COOKIE['KEY-' . $reportId] ?? ''); 169 | return $fileContent; 170 | } 171 | 172 | private static function getSelectedReportSettings() { 173 | $reportId = self::getSelectedReportId(); 174 | 175 | $filePath = dirname(__FILE__) . '/../../tmp/' . basename($reportId); 176 | 177 | if (strlen($reportId) !== 32 || !file_exists($filePath)) { 178 | throw new NotFoundException('report'); 179 | } 180 | 181 | return json_decode(base64_decode($_COOKIE['SETTINGS-' . $reportId] ?? ''), true); 182 | } 183 | 184 | private static function getSelectedReportId() { 185 | return $_GET['id'] ?? $_POST['id'] ?? null; 186 | } 187 | 188 | private static function setCookie($name, $value) { 189 | setcookie($name, $value, time() + 60 * 60 * 12, '', '', true, true); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/app/Models/EarningsBag.php: -------------------------------------------------------------------------------- 1 | earnings = [ 31 | 'capital_gains' => 0.0 32 | ]; 33 | 34 | $this->interestTypes = $exchangeInterestTypes; 35 | } 36 | 37 | /** 38 | * Add an earning amount. 39 | * 40 | * @param string $exchange 41 | * @param string $category 42 | * @param string $type 43 | * @param float $value 44 | */ 45 | public function addEarning($exchange, $category, $type, $value) { 46 | if ($category === self::CAPITAL_GAINS) { 47 | $this->earnings['capital_gains'] += $value; 48 | return; 49 | } 50 | 51 | if ($type === self::RAC) { 52 | // if the type is RAC (realizzato anno corrente), then we have to decrement the NR (non realizzato) total value 53 | $this->addEarning($exchange, $category, self::NR, - $value); 54 | } 55 | 56 | if (!isset($this->earnings[$category][$exchange][$type])) { 57 | $this->earnings[$category][$exchange][$type] = 0.0; 58 | } 59 | 60 | $this->earnings[$category][$exchange][$type] += $value; 61 | } 62 | 63 | /** 64 | * Get the selected fiscal year capital gains. 65 | * 66 | * @return float 67 | */ 68 | public function getCapitalGains() { 69 | return $this->earnings[self::CAPITAL_GAINS] ?? 0.0; 70 | } 71 | 72 | /** 73 | * Get the selected fiscal year interests received value. 74 | * 75 | * @return float 76 | */ 77 | public function getInterests($type = self::INTEREST_RL) { 78 | return $this->getCategoryTotalValue(Transaction::INTEREST, $type); 79 | } 80 | 81 | /** 82 | * Get the selected fiscal year airdrop received value. 83 | * 84 | * @return float 85 | */ 86 | public function getAirdropReceived() { 87 | return $this->getCategoryTotalValue(Transaction::AIRDROP); 88 | } 89 | 90 | /** 91 | * Get the categories list with the relatives Italian names. 92 | * 93 | * @return array 94 | */ 95 | public function getCategoriesForRender() { 96 | $categories = []; 97 | 98 | foreach (array_keys($this->earnings) AS $category) { 99 | if ($category === self::CAPITAL_GAINS) { 100 | continue; 101 | } 102 | 103 | $key = $category; 104 | 105 | if (isset(Transaction::EARNING_CATEGORIES_IT[$category])) { 106 | $category = Transaction::EARNING_CATEGORIES_IT[$category]; 107 | } 108 | 109 | $categories[$key] = $category; 110 | } 111 | 112 | asort($categories); 113 | 114 | return $categories; 115 | } 116 | 117 | /** 118 | * Get the total value of a given category. 119 | * 120 | * @param string $category 121 | * @return float 122 | */ 123 | private function getCategoryTotalValue($category, $interestType = self::INTEREST_RL) { 124 | $totalValue = 0.0; 125 | 126 | if (!isset($this->earnings[$category])) { 127 | return $totalValue; 128 | } 129 | 130 | foreach ($this->earnings[$category] AS $exchange => $types) { 131 | if ($category === Transaction::INTEREST && $this->getExchangeInterestType($exchange) !== $interestType) { 132 | continue; 133 | } 134 | 135 | foreach ($types AS $type => $value) { 136 | if (in_array($type, [self::RAC, self::NR])) { 137 | $totalValue += $value; 138 | } 139 | } 140 | } 141 | 142 | return $totalValue; 143 | } 144 | 145 | /** 146 | * Get the info used for the report rendering. 147 | * 148 | * @return array 149 | */ 150 | public function getInfoForRender() { 151 | $earnings = [ 152 | 'capital_gains' => $this->earnings['capital_gains'] 153 | ]; 154 | 155 | foreach ($this->earnings AS $category => $exchanges) { 156 | if ($category === self::CAPITAL_GAINS) continue; 157 | 158 | foreach (self::TYPES AS $type) { 159 | if (!isset($earnings[$type][$category])) { 160 | $earnings[$type][$category] = 0.0; 161 | } 162 | } 163 | 164 | foreach ($exchanges AS $types) { 165 | foreach ($types AS $type => $value) { 166 | $earnings[$type][$category] += $value; 167 | } 168 | } 169 | } 170 | 171 | return $earnings; 172 | } 173 | 174 | /** 175 | * Get the detailed info used for the report rendering. 176 | * 177 | * @return array 178 | */ 179 | public function getDetailedInfoForRender() { 180 | $detailedEarnings = $this->earnings; 181 | 182 | unset($detailedEarnings[self::CAPITAL_GAINS]); 183 | 184 | foreach ($detailedEarnings AS $category => &$exchanges) { 185 | foreach ($exchanges AS $exchange => $types) { 186 | foreach (self::TYPES AS $type) { 187 | if (!isset($detailedEarnings[$category][$exchange][$type])) { 188 | $detailedEarnings[$category][$exchange][$type] = 0.0; 189 | } 190 | } 191 | } 192 | ksort($exchanges); 193 | } 194 | 195 | ksort($detailedEarnings); 196 | 197 | return $detailedEarnings; 198 | } 199 | 200 | public function getExchangeInterestTypes() { 201 | $exchangeInterestTypes = []; 202 | $detailedEarnings = $this->earnings; 203 | 204 | unset($detailedEarnings[self::CAPITAL_GAINS]); 205 | 206 | foreach (array_keys($detailedEarnings[Transaction::INTEREST] ?? []) AS $exchange) { 207 | if (!isset($exchangeInterestTypes[$exchange])) { 208 | $exchangeInterestTypes[$exchange] = $this->getExchangeInterestType($exchange); 209 | } 210 | } 211 | 212 | return $exchangeInterestTypes; 213 | } 214 | 215 | public function getExchangeInterestList() { 216 | $exchanges = []; 217 | $exchangesCleaned = []; 218 | 219 | foreach (array_keys($this->getExchangeInterestTypes()) AS $exchange) { 220 | $exchangeCleaned = strtolower(str_replace(' ', '', $exchange)); 221 | if (!in_array($exchangeCleaned, $exchangesCleaned)) { 222 | $exchanges[] = $exchange; 223 | $exchangesCleaned[] = $exchangeCleaned; 224 | } 225 | } 226 | 227 | return $exchanges; 228 | } 229 | 230 | private function getExchangeInterestType($exchange) { 231 | $exchange = array_filter(array_keys($this->interestTypes), function ($currExchange) use ($exchange) { 232 | return strtolower(str_replace(' ', '', $currExchange)) === strtolower(str_replace(' ', '', $exchange)); 233 | }); 234 | 235 | $exchange = array_values($exchange); 236 | 237 | if (isset($exchange[0])) { 238 | $exchange = $exchange[0]; 239 | } else { 240 | return self::INTEREST_RL; 241 | } 242 | 243 | return strtoupper($this->interestTypes[$exchange] ?? '') === self::INTEREST_RM ? self::INTEREST_RM : self::INTEREST_RL; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/app/Models/CryptoInfo.php: -------------------------------------------------------------------------------- 1 | ticker = CryptoInfoUtils::getCryptoTicker($ticker); 69 | $this->name = CryptoInfoUtils::getCryptoName($ticker); 70 | $this->fiscalYear = $fiscalYear; 71 | } 72 | 73 | /** 74 | * Save the balance at the beginning of the fiscal year. 75 | * 76 | * @return void 77 | */ 78 | public function saveBalanceStartOfYear() { 79 | $this->balanceStartOfYear = $this->balance; 80 | } 81 | 82 | /** 83 | * Set the daily balances to the current ones until reach the specific day of year. 84 | * 85 | * @param integer $day day of the year, 0-365 86 | * @return void 87 | */ 88 | public function setBalancesUntilDay($day) { 89 | while ($this->currentDayOfYear < $day) { 90 | $this->dailyBalances[$this->currentDayOfYear] = $this->balance; 91 | $this->currentDayOfYear++; 92 | } 93 | } 94 | 95 | /** 96 | * Increment the cryptocurrency balance. 97 | * 98 | * @param float $amount 99 | * @param Transaction $transaction 100 | * @return void 101 | */ 102 | public function incrementBalance($amount, $transaction = null) { 103 | $this->balance += $amount; 104 | 105 | if ($this->balance < 0 && abs($this->balance) > pow(10, -12)) { 106 | throw new NegativeBalanceException($this->ticker, $this->balance, $transaction ? $transaction->date : null); 107 | } 108 | } 109 | 110 | /** 111 | * Get the cryptocurrency price at the day 01/01 of the selected fiscal year. 112 | * 113 | * @return float 114 | */ 115 | public function getPriceStartOfYear() { 116 | if (array_sum($this->dailyBalances) === 0.0) { 117 | return 0.0; 118 | } 119 | 120 | return CryptoInfoUtils::getCryptoPrice($this->ticker, DateUtils::getFirstDayOfYear($this->fiscalYear)); 121 | } 122 | 123 | /** 124 | * Get the cryptocurrency price at the day 31/12 of the selected fiscal year. 125 | * 126 | * @return float 127 | */ 128 | public function getPriceEndOfYear() { 129 | $this->setBalancesUntilDay(DateUtils::old_getNumerOfDaysInYear($this->fiscalYear) + 1); 130 | 131 | if (array_sum($this->dailyBalances) === 0.0) { 132 | return 0.0; 133 | } 134 | 135 | return CryptoInfoUtils::getCryptoPrice($this->ticker, DateUtils::getLastDayOfYear($this->fiscalYear)); 136 | } 137 | 138 | /** 139 | * Get the cryptocurrency EUR value at the day 01/01 of the selected fiscal year. 140 | * 141 | * @return float 142 | */ 143 | public function getValueStartOfYear() { 144 | return $this->getPriceStartOfYear() * $this->balanceStartOfYear; 145 | } 146 | 147 | /** 148 | * Get the cryptocurrency EUR value at the day 31/12 of the selected fiscal year. 149 | * 150 | * @return float 151 | */ 152 | public function getValueEndOfYear() { 153 | return $this->getPriceEndOfYear() * $this->balance; 154 | } 155 | 156 | /** 157 | * Get the average EUR value (giacenza media) in the selected fiscal year. 158 | * 159 | * @param string $priceDate the specified day price is used 160 | * @return float 161 | */ 162 | public function getAverageValue($priceDate = '12-31') { 163 | $dailyBalancesSum = array_sum($this->dailyBalances); 164 | 165 | if ($dailyBalancesSum === 0.0) { 166 | return 0.0; 167 | } 168 | 169 | $daysInYear = DateUtils::getNumberOfDaysInYear($this->fiscalYear); 170 | $price = CryptoInfoUtils::getCryptoPrice($this->ticker, $this->fiscalYear . '-' . $priceDate); 171 | 172 | return $dailyBalancesSum / $daysInYear * $price; 173 | } 174 | 175 | /** 176 | * Get the maximum EUR value in the selected fiscal year. 177 | * 178 | * @param string $priceDate the specified day price is used 179 | * @return [type] [description] 180 | */ 181 | public function getMaxValue($priceDate = '12-31') { 182 | if (array_sum($this->dailyBalances) === 0.0) { 183 | return 0.0; 184 | } 185 | 186 | $price = CryptoInfoUtils::getCryptoPrice($this->ticker, $this->fiscalYear . '-' . $priceDate); 187 | 188 | return max($this->dailyBalances) * $price; 189 | } 190 | 191 | /** 192 | * Get the daily values using the price at the beginning of the fiscal year. 193 | * 194 | * @return float[] 195 | */ 196 | public function getDailyValuesStartOfYear() { 197 | return $this->getDailyValues($this->fiscalYear . '-01-01'); 198 | } 199 | 200 | /** 201 | * Get the daily values using the price at the end of the fiscal year. 202 | * 203 | * @return float[] 204 | */ 205 | public function getDailyValuesEndOfYear() { 206 | return $this->getDailyValues($this->fiscalYear . '-12-31'); 207 | } 208 | 209 | /** 210 | * Get the daily values using the price at the specified date. 211 | * If priceDate is null, real daily prices are used. 212 | * 213 | * @param string $priceDate 214 | * @return float[] 215 | */ 216 | public function getDailyValues($priceDate = null) { 217 | if (array_sum($this->dailyBalances) === 0.0) { 218 | return $this->dailyBalances; 219 | } 220 | 221 | return array_map(function ($balance, $day) use ($priceDate) { 222 | if ($priceDate === null) { 223 | $dateToFetch = DateUtils::getDateFromDayOfYear($day, $this->fiscalYear); 224 | } else { 225 | $dateToFetch = $priceDate; 226 | } 227 | 228 | $price = CryptoInfoUtils::getCryptoPrice($this->ticker, $dateToFetch); 229 | 230 | return $balance * $price; 231 | }, $this->dailyBalances, array_keys($this->dailyBalances)); 232 | } 233 | 234 | /** 235 | * Get the info used for the report rendering. 236 | * 237 | * @return array 238 | */ 239 | public function getInfoForRender() { 240 | $info = [ 241 | 'name' => $this->name, 242 | 'ticker' => $this->ticker, 243 | 'price_start_of_year' => $this->getPriceStartOfYear(), 244 | 'price_end_of_year' => $this->getPriceEndOfYear(), 245 | 'balance_start_of_year' => $this->balanceStartOfYear, 246 | 'balance_end_of_year' => $this->balance, 247 | 'value_start_of_year' => $this->getValueStartOfYear(), 248 | 'value_end_of_year' => $this->getValueEndOfYear(), 249 | 'average_value' => $this->getAverageValue(), 250 | 'max_value' => $this->getMaxValue() 251 | ]; 252 | 253 | $info['balance_start_of_year'] = $this->formatBalance($info['balance_start_of_year'], $info['price_start_of_year']); 254 | $info['balance_end_of_year'] = $this->formatBalance($info['balance_end_of_year'], $info['price_end_of_year']); 255 | 256 | foreach (['price_start_of_year', 'price_end_of_year'] AS $field) { 257 | $info[$field] = number_format($info[$field], 3, ',', '.'); 258 | } 259 | 260 | foreach (['value_start_of_year', 'value_end_of_year', 'average_value', 'max_value'] AS $field) { 261 | $info[$field] = number_format($info[$field], 2, ',', '.'); 262 | } 263 | 264 | return $info; 265 | } 266 | 267 | /** 268 | * Format the balance based on the price. 269 | * 270 | * @param float $balance 271 | * @param float $price 272 | * @return string 273 | */ 274 | private function formatBalance($balance, $price) { 275 | $digits = 2; 276 | 277 | if ($price > 10 && $balance > 0) { 278 | $digits = 8; 279 | } 280 | 281 | return number_format($balance, $digits, ',', '.'); 282 | } 283 | 284 | } 285 | -------------------------------------------------------------------------------- /src/app/Models/ReportWrapper.php: -------------------------------------------------------------------------------- 1 | report = new Report($transactionsFileContent, $exchangeInterestTypes, $considerEarningsAndExpensesAsInvestment); 16 | } 17 | 18 | public function getSummary($rawValues = false) { 19 | $currentYear = $this->report->getCurrentYear(); 20 | 21 | if (!$currentYear) { 22 | $currentYear = DateUtils::getCurrentYear(); 23 | } 24 | 25 | $reportSummaries = []; 26 | $exchangeInterestList = []; 27 | $yearsList = []; 28 | $hasEarningsOrExpenses = false; 29 | 30 | for ($year = $this->report->getFirstYear(); $year <= $this->report->getLastYear(); $year++) { 31 | $this->report->elaborateReport($year); 32 | $summary = $this->report->getSummary(true); 33 | 34 | if ($summary['total_values']['average_value'] > 0.01) { 35 | $reportSummaries['years'][$year] = $summary; 36 | 37 | $exchangeInterestList = array_merge($exchangeInterestList, $reportSummaries['years'][$year]['interest_exchanges']); 38 | $hasEarningsOrExpenses = $hasEarningsOrExpenses || $reportSummaries['years'][$year]['has_earnings_or_expenses']; 39 | $yearsList[] = $year; 40 | } 41 | } 42 | 43 | $reportSummaries = $this->calculateCapitalLossesCompensation($reportSummaries); 44 | $reportSummaries['interest_exchanges'] = array_values(array_unique($exchangeInterestList)); 45 | $reportSummaries['has_earnings_or_expenses'] = $hasEarningsOrExpenses; 46 | $reportSummaries['years_list'] = $yearsList; 47 | 48 | $this->report->elaborateReport($currentYear); 49 | 50 | if ($rawValues) { 51 | return $reportSummaries; 52 | } else { 53 | return NumberUtils::recursiveFormatNumbers($reportSummaries, 0, true); 54 | } 55 | } 56 | 57 | public function elaborateReport($year) { 58 | $this->report->elaborateReport($year); 59 | } 60 | 61 | public function getInfoForRender() { 62 | return $this->report->getInfoForRender(); 63 | } 64 | 65 | public function getReport($year) { 66 | $this->elaborateReport($year); 67 | 68 | $loader = new \Twig\Loader\FilesystemLoader('resources/views/'); 69 | $twig = new \Twig\Environment($loader); 70 | 71 | $baseUrl = '//' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; 72 | 73 | if (strrpos($baseUrl, '?')) { 74 | $baseUrl = substr($baseUrl, 0, strrpos($baseUrl, '?')); 75 | } 76 | 77 | $years = array_keys($this->getSummary()['years']); 78 | 79 | $yearSelectors = []; 80 | 81 | foreach ($years AS $thisYear) { 82 | $yearSelectors[$thisYear] = $baseUrl . '?' . http_build_query([ 83 | 'year' => $thisYear 84 | ]); 85 | } 86 | 87 | $forms = []; 88 | 89 | if ($this->report->shouldFillModelloRedditi()) { 90 | $forms['pf'] = $baseUrl . '?' . http_build_query([ 91 | 'year' => $year, 92 | 'action' => 'pdf_modello_redditi' 93 | ]); 94 | } 95 | 96 | if (($this->report->shouldFillRT() && $this->report->getCapitalGainsTax() > 0) || $this->report->shouldFillRM()) { 97 | $forms['f24'] = $baseUrl . '?' . http_build_query([ 98 | 'year' => $year, 99 | 'action' => 'pdf_modello_f24' 100 | ]); 101 | } 102 | 103 | return $twig->render('report.html', $this->report->getInfoForRender() + [ 104 | 'header' => HEADER, 105 | 'years' => $yearSelectors, 106 | 'forms' => $forms, 107 | 'form_names' => [ 108 | 'pf' => 'Redditi Persone Fisiche ' . ($year + 1), 109 | 'f24' => 'Modello di pagamento F24' 110 | ], 111 | 'software_version' => VersionUtils::getVersion(), 112 | 'date' => date('d/m/Y'), 113 | 'time' => date('H:i:s'), 114 | 'warnings' => CryptoInfoUtils::getWarnings(intval($year)), 115 | 'mode_private' => !defined('MODE') || MODE === 'private' 116 | ]); 117 | } 118 | 119 | public function getModelloRedditi($year, $compensateCapitalLosses = true, $finalValueMethod = 'average_value') { 120 | $this->elaborateReport($year); 121 | 122 | $info = $this->report->getInfoForModelloRedditi() + [ 123 | 'rl' => $this->getSectionRlInfo($year), 124 | 'rw' => $this->report->getModelloRedditiSectionRwInfo($finalValueMethod), 125 | 'rt' => $this->getSectionRtInfo($year, $compensateCapitalLosses), 126 | 'rm' => $this->getSectionRmInfo($year) 127 | ]; 128 | 129 | $modelloRedditi = new ModelloRedditi($info); 130 | return $modelloRedditi->getPdf(); 131 | } 132 | 133 | public function getModelloF24($year, $compensateCapitalLosses = true) { 134 | $this->elaborateReport($year); 135 | 136 | $taxes = []; 137 | 138 | if ($this->report->shouldFillRT()) { 139 | $rtInfo = $this->getSectionRtInfo($year, true); 140 | $capitalGainsTax = $compensateCapitalLosses ? $rtInfo['capital_gains_compensated_tax'] : $rtInfo['capital_gains_tax']; 141 | 142 | if ($capitalGainsTax > 0) { 143 | $taxes[] = ['code' => 1100, 'amount' => $capitalGainsTax]; 144 | } 145 | } 146 | 147 | if ($this->report->shouldFillRM()) { 148 | $rmInfo = $this->getSectionRmInfo($year); 149 | $taxes[] = ['code' => 1242, 'amount' => $rmInfo['tax']]; 150 | } 151 | 152 | $modelloF24 = new ModelloF24($year, $taxes); 153 | return $modelloF24->getPdf(); 154 | } 155 | 156 | public function getSectionRtInfo($year, $compensateCapitalLosses) { 157 | $summary = $this->getSummary(); 158 | 159 | $this->elaborateReport($year); 160 | $capitalGains = round($this->report->getCapitalGains()); 161 | 162 | $info = [ 163 | 'total_incomes' => round($this->report->currentYearPurchaseCost) + $capitalGains, 164 | 'total_costs' => round($this->report->currentYearPurchaseCost), 165 | 'capital_gains' => $capitalGains > 0 ? $capitalGains : '', 166 | 'capital_losses' => $capitalGains < 0 ? abs($capitalGains) : '', 167 | 'capital_losses_previous_years' => max(0, $capitalGains - $summary['years'][$year]['capital_gains_compensated']), 168 | 'capital_gains_compensated' => round($summary['years'][$year]['capital_gains_compensated']), 169 | 'capital_gains_compensated_tax' => max(0, round(round($summary['years'][$year]['capital_gains_compensated']) * Report::CAPITAL_GAINS_TAX_RATE)), 170 | 'capital_gains_tax' => max(0, round($capitalGains * Report::CAPITAL_GAINS_TAX_RATE)), 171 | 'compensate_capital_losses' => $compensateCapitalLosses, 172 | 'remaining_capital_losses' => [] 173 | ]; 174 | 175 | for ($i = 4; $i >= 0; $i--) { 176 | $info['remaining_capital_losses'][$year - $i] = $summary['years'][$year - $i]['remaining_capital_losses'][$year] ?? 0; 177 | } 178 | 179 | return $info; 180 | } 181 | 182 | public function getSectionRlInfo($year) { 183 | $this->elaborateReport($year); 184 | 185 | return [ 186 | 'interests' => round($this->report->getInterests(EarningsBag::INTEREST_RL)) 187 | ]; 188 | } 189 | 190 | public function getSectionRmInfo($year) { 191 | $this->elaborateReport($year); 192 | 193 | return [ 194 | 'interests' => round($this->report->getInterests(EarningsBag::INTEREST_RM)), 195 | 'tax_rate' => round(Report::INTERESTS_EARNING_TAX_RATE * 100), 196 | 'tax' => round(round($this->report->getInterests(EarningsBag::INTEREST_RM)) * Report::INTERESTS_EARNING_TAX_RATE) 197 | ]; 198 | } 199 | 200 | private function calculateCapitalLossesCompensation($summaries) { 201 | $totalCompensation = 0; 202 | 203 | foreach ($summaries['years'] AS $year => &$summary) { 204 | $summary['remaining_capital_losses'][$year] = 0; 205 | $summary['capital_gains_compensated'] = 0; 206 | 207 | if (!$summary['no_tax_area_threshold_exceeded'] || $year < 2016) { 208 | // do nothing if the no-tax area threshold has not been exceeded in this year or if fiscal year is before 2016 209 | // TODO: controllare se $year < 2016 porta al comportamento corretto 210 | continue; 211 | } 212 | 213 | if ($summary['capital_gains_and_airdrop'] >= 0) { 214 | $summary['capital_gains_compensated'] = round($summary['capital_gains_and_airdrop']); 215 | } else { 216 | // if capital gains are less than 0, then they are actually capital losses 217 | $summary['remaining_capital_losses'][$year] = round(abs($summary['capital_gains_and_airdrop'])); 218 | } 219 | 220 | // check if we can compensate capital gains with capital losses of the previous 4 years 221 | for ($i = 4; $i > 0; $i--) { 222 | if ($summary['capital_gains_compensated'] > 0 && ($summaries['years'][$year - $i]['remaining_capital_losses'][$year - 1] ?? 0) > 0) { 223 | // if in the year [$year - 1] there are remaining capital losses, we reduce the capital gains of this year 224 | $compensation = min($summary['capital_gains_compensated'], $summaries['years'][$year - $i]['remaining_capital_losses'][$year]); 225 | $summary['capital_gains_compensated'] -= $compensation; 226 | $summaries['years'][$year - $i]['remaining_capital_losses'][$year] -= $compensation; 227 | $totalCompensation += $compensation; 228 | 229 | // update the remaining capital losses for any following years 230 | for ($j = $year; $j <= $year - $i + 4; $j++) { 231 | $summaries['years'][$year - $i]['remaining_capital_losses'][$j] = $summaries['years'][$year - $i]['remaining_capital_losses'][$year]; 232 | } 233 | } 234 | } 235 | 236 | // set the remaining capital losses for the following 4 years 237 | for ($i = 0; $i <= 4; $i++) { 238 | $summary['remaining_capital_losses'][$year + $i] = $summary['remaining_capital_losses'][$year]; 239 | } 240 | } 241 | 242 | $summaries['total_compensation'] = $totalCompensation; 243 | 244 | return $summaries; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/app/Utils/CryptoInfoUtils.php: -------------------------------------------------------------------------------- 1 | ($year === null || $year < 2009) ? self::$requestedTooEarlyPrices : false, 99 | 'too_early_date' => DateUtils::getDateFromString('2009-01-03'), 100 | 'too_late' => ($year === null || $year === DateUtils::getCurrentYear()) ? self::$requestedTooLatePrices : false, 101 | 'too_late_date' => DateUtils::getDateFromString('-3 days'), 102 | 'prices' => self::getNotFoundPrices($year) 103 | ]; 104 | 105 | $warnings['show'] = $warnings['too_early'] || $warnings['too_late'] || count($warnings['prices']) > 0; 106 | 107 | return $warnings; 108 | } 109 | 110 | private static function getNotFoundPrices($year = null) { 111 | $notFoundPrices = []; 112 | 113 | foreach (self::$prices AS $ticker => $dates) { 114 | $firstDate = null; 115 | $lastDate = null; 116 | 117 | ksort($dates); 118 | foreach ($dates AS $date => $value) { 119 | if ($year !== null && $year !== intval(date('Y', strtotime($date)))) { 120 | continue; 121 | } 122 | 123 | if (!$value['found']) { 124 | if ($value['required']) { 125 | if ($firstDate === null) { 126 | $firstDate = $date; 127 | $lastDate = $date; 128 | } else { 129 | $lastDate = $date; 130 | } 131 | } 132 | } else { 133 | if ($firstDate !== null) { 134 | $notFoundPrices[] = ['ticker' => $ticker, 'from' => $firstDate, 'to' => $lastDate]; 135 | } 136 | $firstDate = null; 137 | $lastDate = null; 138 | } 139 | } 140 | 141 | if ($firstDate !== null) { 142 | $notFoundPrices[] = ['ticker' => $ticker, 'from' => $firstDate, 'to' => $lastDate]; 143 | } 144 | $firstDate = null; 145 | $lastDate = null; 146 | } 147 | 148 | return $notFoundPrices; 149 | } 150 | 151 | /** 152 | * Get a cryptocurrency info and price on a specific date. 153 | * 154 | * @param string $ticker 155 | * @param string $date 156 | * @return array 157 | */ 158 | private static function getCryptoData($ticker, $date) { 159 | $date = DateUtils::getDateFromString($date); 160 | 161 | if (isset(CUSTOM_TICKERS[$ticker])) { 162 | $ticker = CUSTOM_TICKERS[$ticker]; 163 | } 164 | 165 | if (self::isEurStablecoin($ticker)) { 166 | return [ 167 | 'name' => self::getEurStablecoinRealTicker($ticker) . ' stablecoin', 168 | 'ticker' => self::getEurStablecoinRealTicker($ticker), 169 | 'price' => 1.0, 170 | 'required' => true, 171 | 'found' => true, 172 | 'fetched' => true 173 | ]; 174 | } 175 | 176 | // prices before the creation of Bitcoin do not exist 177 | if ($date < DateUtils::getDateFromString('2009-01-03')) { 178 | self::$requestedTooEarlyPrices = true; 179 | return self::getCryptoData($ticker, DateUtils::getDateFromString('2009-01-03')); 180 | } 181 | 182 | // prices of the last 3 days may not yet be available 183 | if ($date > DateUtils::getDateFromString('-3 days')) { 184 | self::$requestedTooLatePrices = true; 185 | return self::getCryptoData($ticker, DateUtils::getDateFromString('-3 days')); 186 | } 187 | 188 | $ticker = strtoupper($ticker); 189 | 190 | if (!isset(self::$prices[$ticker][$date])) { 191 | self::$prices[$ticker][$date] = [ 192 | 'name' => $ticker, 193 | 'ticker' => $ticker, 194 | 'price' => 0.0, 195 | 'required' => true, 196 | 'found' => false, 197 | 'fetched' => false 198 | ]; 199 | 200 | self::fetchCryptoData($ticker, $date); 201 | } 202 | 203 | self::$prices[$ticker][$date]['required'] = true; 204 | 205 | return self::$prices[$ticker][$date]; 206 | } 207 | 208 | /** 209 | * Fetch cryptocurrency data from database cache and API. 210 | * 211 | * @param string $ticker 212 | * @param string $date 213 | * @return void 214 | */ 215 | private static function fetchCryptoData($ticker, $date) { 216 | self::fetchCryptoDataFromDb($ticker, $date); 217 | self::fetchCryptoDataFromApi($ticker, $date); 218 | } 219 | 220 | /** 221 | * To reduce number of DB/API calls, each time a predefined length range of dates is requested. 222 | * This method calculates the first and the last date of the range. 223 | * 224 | * @param string $ticker 225 | * @param string $date 226 | * @param integer $rangeDays 227 | * @return array 228 | */ 229 | private static function getDateRangeToFetch($ticker, $date, $rangeDays = 366) { 230 | $firstDate = DateUtils::getDateFromString($date . ' - ' . (intval($rangeDays / 2)) . ' days'); 231 | $lastDate = DateUtils::getDateFromString($date . ' + ' . (intval($rangeDays / 2)) . ' days'); 232 | 233 | if ($firstDate > DateUtils::getDateFromString('-3 days')) { 234 | $firstDate = DateUtils::getDateFromString('-3 days'); 235 | } 236 | 237 | if ($lastDate > DateUtils::getDateFromString('-3 days')) { 238 | $lastDate = DateUtils::getDateFromString('-3 days'); 239 | } 240 | 241 | while ((self::$prices[$ticker][$firstDate]['fetched'] ?? false) && $firstDate <= $lastDate) { 242 | $firstDate = DateUtils::getDateFromString($firstDate . ' + 1 day'); 243 | } 244 | 245 | while ((self::$prices[$ticker][$lastDate]['fetched'] ?? false) && $firstDate <= $lastDate) { 246 | $lastDate = DateUtils::getDateFromString($lastDate . ' - 1 day'); 247 | } 248 | 249 | return [$firstDate, $lastDate]; 250 | } 251 | 252 | /** 253 | * Fetch cryptocurrency data from database cache. 254 | * 255 | * @param string $ticker 256 | * @param string $date 257 | * @return void 258 | */ 259 | private static function fetchCryptoDataFromDb($ticker, $date) { 260 | [$firstDate, $lastDate] = self::getDateRangeToFetch($ticker, $date); 261 | 262 | if ($firstDate > $lastDate) { 263 | // if firstDate and lastDate overlap, all the required data have already been fetched 264 | return; 265 | } 266 | 267 | $currentTime = defined('FAKE_CACHE_EXPIRATION') ? (time() + (60 * 60)) : time(); 268 | $stmt = DbUtils::getConnection()->prepare('SELECT date, quote, ticker, name, found FROM cache WHERE ticker = ? AND date >= ? AND date <= ? AND (expiration = 0 OR expiration > ?)'); 269 | $stmt->bind_param('sssi', $ticker, $firstDate, $lastDate, $currentTime); 270 | $stmt->execute(); 271 | $result = $stmt->get_result(); 272 | $stmt->close(); 273 | 274 | while ($resultArray = $result->fetch_assoc()) { 275 | $resultDate = $resultArray['date']; 276 | 277 | self::$prices[$ticker][$resultDate] = [ 278 | 'name' => $resultArray['name'], 279 | 'ticker' => $resultArray['ticker'], 280 | 'price' => $resultArray['quote'], 281 | 'required' => self::$prices[$ticker][$resultDate]['required'] ?? false, 282 | 'fetched' => true, 283 | 'found' => $resultArray['found'] ? true : false 284 | ]; 285 | } 286 | } 287 | 288 | /** 289 | * Fetch cryptocurrency data from cryptohistory.one API. 290 | * 291 | * @param string $ticker 292 | * @param string $date 293 | * @return void 294 | */ 295 | private static function fetchCryptoDataFromApi($ticker, $date) { 296 | [$firstDate, $lastDate] = self::getDateRangeToFetch($ticker, $date); 297 | 298 | if ($firstDate > $lastDate) { 299 | // if firstDate and lastDate overlap, all the required data have already been fetched 300 | return; 301 | } 302 | 303 | $values = json_decode(@file_get_contents(self::API_HOST . '/' . $ticker . '/' . $firstDate . '/' . $lastDate), true); 304 | 305 | if ($values === null) { 306 | // The request to the API did not produce any result 307 | // (the cryptocurrency was not found, or a communication error was encountered). 308 | // Consider the price = 0 and cache it for 24 hours. 309 | $values = []; 310 | $currDate = $firstDate; 311 | $price = 0.0; 312 | 313 | while ($currDate <= $lastDate) { 314 | $values[] = [ 315 | 'date' => $currDate, 316 | 'name' => $ticker, 317 | 'ticker' => $ticker, 318 | 'price_eur' => 0.0, 319 | 'cache_max_age' => 60 * 60 * 24, 320 | 'found' => false 321 | ]; 322 | 323 | $currDate = DateUtils::getDateFromString($currDate . ' + 1 day'); 324 | } 325 | } 326 | 327 | if (is_iterable($values)) { 328 | if (isset($values['ticker'])) { 329 | // a single day price was returned; put it in an array to make the next foreach work 330 | $values = [$values]; 331 | } 332 | 333 | foreach ($values AS $value) { 334 | $price = floatVal($value['price_eur']); 335 | $found = $value['found'] ? 1 : 0; 336 | $expiration = ($value['found'] ? 0 : time() + min($value['cache_max_age'], 60 * 60 * 24 * 30)); 337 | $stmt = DbUtils::getConnection()->prepare('INSERT INTO cache (ticker, name, date, quote, expiration, found) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE quote = ?, expiration = ?, found = ?'); 338 | $stmt->bind_param('sssdiidii', $value['ticker'], $value['name'], $value['date'], $price, $expiration, $found, $price, $expiration, $found); 339 | $stmt->execute(); 340 | $stmt->close(); 341 | } 342 | } 343 | 344 | self::fetchCryptoDataFromDb($ticker, $date); 345 | } 346 | 347 | private static function isEurStablecoin(string $ticker): bool { 348 | return self::getEurStablecoinRealTicker($ticker) !== null; 349 | } 350 | 351 | private static function getEurStablecoinRealTicker(string $ticker): ?string { 352 | foreach (self::EUR_STABLECOINS AS $stablecoin) { 353 | if (strtolower($stablecoin) === strtolower($ticker)) { 354 | return $stablecoin; 355 | } 356 | } 357 | 358 | return null; 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/resources/views/report.html: -------------------------------------------------------------------------------- 1 | 2 |
3 || Criptovaluta | 38 |Prezzo 01/01/{{ fiscal_year }} | 39 |Prezzo 31/12/{{ fiscal_year }} | 40 |Saldo 01/01/{{ fiscal_year }} | 41 |Controvalore 01/01/{{ fiscal_year }} | 42 |Saldo 31/12/{{ fiscal_year }} | 43 |Controvalore 31/12/{{ fiscal_year }} | 44 |Giacenza media 1) | 45 |Valore massimo 1) | 46 ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ crypto['name'] }} | 51 |{{ crypto['ticker'] }} | 52 |{{ crypto['price_start_of_year'] }} | 53 |€ | 54 |{{ crypto['price_end_of_year'] }} | 55 |€ | 56 |{{ crypto['balance_start_of_year'] }} | 57 |¤ | 58 |{{ crypto['value_start_of_year'] }} | 59 |€ | 60 |{{ crypto['balance_end_of_year'] }} | 61 |¤ | 62 |{{ crypto['value_end_of_year'] }} | 63 |€ | 64 |{{ crypto['average_value'] }} | 65 |€ | 66 |{{ crypto['max_value'] }} | 67 |€ | 68 |
| TOTALE | 73 |n.a. | 74 |n.a. | 75 |n.a. | 76 |{{ summary['total_values']['value_start_of_year'] }} | 77 |€ | 78 |n.a. | 79 |{{ summary['total_values']['value_end_of_year'] }} | 80 |€ | 81 |{{ summary['total_values']['average_value'] }} | 82 |€ | 83 |{{ summary['total_values']['max_value'] }} | 84 |€ | 85 ||||||
| Guadagni | 94 ||||||||
|---|---|---|---|---|---|---|---|
| Tipologia | 97 |Realizzati | 98 |Non realizzati 4) | 99 ||||||
| anni precedenti 3) | 102 |anno corrente | 103 |||||||
| Plusvalenze | 106 |n.a. | 107 |{{ earnings['capital_gains']}} | 108 |€ | n.a. | 109 ||||
| {{ earnings_categories[category] | capitalize }} | 114 |{{ earnings['rap'][category] }} | 115 |€ | 116 |{{ earnings['rac'][category] }} | 117 |€ | 118 |{{ earnings['nr'][category] }} | 119 |€ | 120 ||
| Dettaglio {{ earnings_categories[category] }} | 128 |||||||||
|---|---|---|---|---|---|---|---|---|
| Provenienza | 131 |Realizzati | 132 |Non realizzati 4) | 133 | 134 | {% if category is same as('interest') %} 135 |Quadro | 136 | {% endif %} 137 ||||||
| anni precedenti 3) | 140 |anno corrente | 141 ||||||||
| {{ exchange is same as ('') ? 'n.d.' : exchange }} | 146 |{{ values['rap'] }} | 147 |€ | 148 |{{ values['rac'] }} | 149 |€ | 150 |{{ values['nr'] }} | 151 |€ | 152 | 153 | {% if category is same as('interest') %} 154 |{{ exchange_interest_types[exchange] }} | 155 | {% endif %} 156 ||
| Investimenti anno corrente 2) | 166 |{{ summary['current_year_investment'] }} | 167 |€ | 168 |
|---|---|---|
| 171 | | ||
| Totale dei corrispettivi RT21 | 174 |{{ summary['current_year_income'] }} | 175 |€ | 176 |
| Totale dei costi o dei valori di acquisto RT22 | 179 |{{ summary['current_year_purchase_cost'] }} | 180 |€ | 181 |
| Plusvalenze | 184 |{{ summary['capital_gains'] }} | 185 |€ | 186 |
| Plusvalenze + airdrop RT23 5) | 189 |{{ summary['capital_gains_and_airdrop'] }} | 190 |€ | 191 |
| Imposta sostitutiva RT27 | 194 |{{ summary['capital_gains_tax'] }} | 195 |€ | 196 |
| Superata soglia 51.645,69 € | 199 |{{ summary['no_tax_area_threshold_exceeded'] ? 'sì' : 'no' }} | 200 ||
| Quadro RM | 204 |||
| Redditi di capitale RM12.3 | 207 |{{ summary['interests_rm'] }} | 208 |€ | 209 |
| Imposta sostitutiva RM12.6 | 212 |{{ summary['interests_rm_tax'] }} | 213 |€ | 214 |
| Quadro RL | 218 |||
| Altri redditi di capitale RL2.2 | 221 |{{ summary['interests_rl'] }} | 222 |€ | 223 |
| Imposta stimata (con aliquota marginale {{ rate }}%) | 227 |{{ (summary['interests_rl_raw'] | round * rate / 100) | round | number_format(2, ',', '.') }} | 228 |€ | 229 |
276 | Fonte prezzi: cryptohistory.one. Nel caso non sia disponibile il prezzo al giorno 01/01/{{ fiscal_year }} viene utilizzato il primo prezzo disponibile nell'anno {{ fiscal_year }}.
277 |
280 | 1) calcolato secondo il prezzo al giorno 31/12/{{ fiscal_year }}; 281 | 2) somma dei costi d'acquisto - somma dei corrispettivi di cessione; 282 | 3) guadagni percepiti negli anni precedenti, ma venduti ovvero scambiati con un'altra criptovaluta nell'anno corrente; 283 | 4) guadagni percepiti nell'anno corrente che non sono ancora stati venduti ovvero scambiati con un'altra criptovaluta; 284 | 5) somma delle plusvalenze e degli airdrop ricevuti nell'anno corrente, realizzati e non realizzati. 285 |
286 | 287 |Generato da CrypTax - {{ software_version }} - github.com/cristianlivella/cryptax - in data {{ date }} alle ore {{ time }}.
288 | 289 |290 | ATTENZIONE: CrypTax e Cristian Livella non si assumono nessuna responsabilita riguardo la correttezza 291 | e la completezza dei dati riportati in questo modulo. Il software offre un aiuto nel calcolo 292 | delle plusvalenze e nella compilazione dei modelli, ma è responsabilità del contribuente di verificarne 293 | la correttezza e la compatibilità con la propria situazione finanziaria complessiva. 294 |
295 | 296 | 297 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | -------------------------------------------------------------------------------- /src/app/Models/Report.php: -------------------------------------------------------------------------------- 1 | transactionsBag = new TransactionsBag($transactionsFileContent); 99 | $this->exchangeInterestTypes = $exchangeInterestTypes; 100 | $this->considerEarningsAndExpensesAsInvestment = $considerEarningsAndExpensesAsInvestment; 101 | } 102 | 103 | /** 104 | * Processes the transactions, calculates capital gains and earnings. 105 | * 106 | * @param integer $year 107 | * @return void 108 | */ 109 | public function elaborateReport(int $year) { 110 | if ($this->fiscalYear === $year) { 111 | return; 112 | } 113 | 114 | $this->setFiscalYear($year); 115 | 116 | $firstDayOfYear = DateUtils::getFirstDayOfYear($this->fiscalYear); 117 | $lastDayOfYear = DateUtils::getLastDayOfYear($this->fiscalYear); 118 | 119 | foreach ($this->transactionsBag->transactions AS $tx) { 120 | if ($tx->date > $lastDayOfYear) { 121 | // Don't elaborate transactions made after the end of the selected fiscal year. 122 | break; 123 | } elseif ($tx->date >= $firstDayOfYear) { 124 | // When the first transaction of the fiscal year is encountered, 125 | // save the start of year balance and value for each cryptocurrency. 126 | $this->cryptoInfoBag->saveStartOfYearSnapshot(); 127 | } 128 | 129 | if ($tx->earningCategory || $tx->type === Transaction::EXPENSE) { 130 | $this->hasEarningsOrExpenses = true; 131 | } 132 | 133 | if (DateUtils::getYearFromDate($tx->date) === $this->fiscalYear) { 134 | // Set the daily cryptocurrencies balances until reach the current transaction date. 135 | $this->cryptoInfoBag->setBalancesUntilDay(DateUtils::getDayOfYear($tx->date)); 136 | } 137 | 138 | if ($tx->type === Transaction::PURCHASE) { 139 | $this->cryptoInfoBag->incrementCryptoBalance($tx->ticker, $tx->amount, $tx); 140 | 141 | if (DateUtils::getYearFromDate($tx->date) === $this->fiscalYear) { 142 | if ($tx->earningCategory) { 143 | $this->earningsBag->addEarning($tx->exchange, $tx->earningCategory, EarningsBag::NR, $tx->value); 144 | } 145 | 146 | if (!$tx->earningCategory || $this->considerEarningsAndExpensesAsInvestment) { 147 | $this->currentYearInvestment += $tx->value; 148 | $this->incrementExchangeVolume($tx->exchange, $tx->value); 149 | } 150 | } 151 | } elseif ($tx->type === Transaction::SALE || $tx->type === Transaction::EXPENSE) { 152 | $this->cryptoInfoBag->decrementCryptoBalance($tx->ticker, $tx->amount, $tx); 153 | 154 | if (DateUtils::getYearFromDate($tx->date) === $this->fiscalYear) { 155 | $this->earningsBag->addEarning($tx->exchange, EarningsBag::CAPITAL_GAINS, null, $tx->getCapitalGain()); 156 | 157 | if ($tx->type === Transaction::SALE || $this->considerEarningsAndExpensesAsInvestment) { 158 | $this->currentYearInvestment -= $tx->value; 159 | $this->incrementExchangeVolume($tx->exchange, $tx->value); 160 | } 161 | 162 | $this->currentYearIncome += $tx->value; 163 | $this->currentYearPurchaseCost += $tx->getRelativePurchaseCost(); 164 | 165 | foreach ($tx->getRelativePurchases() AS $purchase) { 166 | $purchaseTx = $purchase['transaction']; 167 | 168 | if ($purchaseTx->earningCategory) { 169 | // this is just the profit realized by selling a crypto earning, it doesn't include capital gain 170 | $realizedProfit = $purchaseTx->value / $purchaseTx->amount * $purchase['amount']; 171 | 172 | if (DateUtils::getYearFromDate($purchaseTx->date) === $this->fiscalYear) { 173 | $type = EarningsBag::RAC; 174 | } else { 175 | $type = EarningsBag::RAP; 176 | } 177 | 178 | $this->earningsBag->addEarning($purchaseTx->exchange, $purchaseTx->earningCategory, $type, $realizedProfit); 179 | } 180 | } 181 | } 182 | } 183 | } 184 | 185 | $this->cryptoInfoBag->saveStartOfYearSnapshot(); 186 | $this->cryptoInfoBag->setBalancesUntilDay(DateUtils::old_getNumerOfDaysInYear($this->fiscalYear) + 1); 187 | $this->cryptoInfoBag->sortByAverageValue(); 188 | } 189 | 190 | /** 191 | * Get the current selected fiscal year. 192 | * 193 | * @return integer 194 | */ 195 | public function getCurrentYear() { 196 | return $this->fiscalYear; 197 | } 198 | 199 | /** 200 | * Get the first fiscal year available for the report. 201 | * 202 | * @return integer|null 203 | */ 204 | public function getFirstYear() { 205 | $firstTransaction = $this->transactionsBag->getFirstTransaction(); 206 | 207 | if ($firstTransaction === null) { 208 | return null; 209 | } 210 | 211 | return DateUtils::getYearFromDate($firstTransaction->date); 212 | } 213 | 214 | /** 215 | * Get the last fiscal year available for the report. 216 | * Actually it always return the current year, if the first fiscal year is not null. 217 | * 218 | * @return integer|null 219 | */ 220 | public function getLastYear() { 221 | if ($this->getFirstYear() === null) { 222 | return null; 223 | } 224 | 225 | return DateUtils::getCurrentYear(); 226 | } 227 | 228 | /** 229 | * Return true if the total holdings values has exceeded €51645.69 for at least 230 | * 7 working days during the current fiscal year, using the prices at 231 | * the start of the year. Art. 67, comma 1-ter TUIR. 232 | * 233 | * @return boolean 234 | */ 235 | public function get51kThresholdExceeded() { 236 | return once(function () { 237 | $dailyValues = $this->cryptoInfoBag->getDailyValuesStartOfYear(); 238 | $daysOverThreshold = 0; 239 | $holidays = DateUtils::getHolidays($this->fiscalYear); 240 | 241 | for ($i = 0; $i <= DateUtils::old_getNumerOfDaysInYear($this->fiscalYear); $i++) { 242 | if (DateUtils::getDayOfWeek($i, $this->fiscalYear) > 0 && !in_array($i, $holidays)) { 243 | if ($dailyValues[$i] > self::CAPITAL_GAINS_NO_TAX_AREA_THRESHOLD) { 244 | $daysOverThreshold++; 245 | } else { 246 | $daysOverThreshold = 0; 247 | } 248 | 249 | if ($daysOverThreshold === 7) { 250 | return true; 251 | } 252 | } 253 | } 254 | 255 | return false; 256 | }); 257 | } 258 | 259 | /** 260 | * Get the exchanges trading volumes. 261 | * 262 | * @return integer[] 263 | */ 264 | public function getExchangeVolumes() { 265 | arsort($this->exchangeVolumes); 266 | return $this->exchangeVolumes; 267 | } 268 | 269 | /** 270 | * Get the capital gains amount for the selected fiscal year. 271 | * Airdrop are considered as capital gains. 272 | * 273 | * @return float 274 | */ 275 | public function getCapitalGains($withoutAirdrop = false) { 276 | return $this->earningsBag->getCapitalGains() + ($withoutAirdrop ? 0.0 : $this->earningsBag->getAirdropReceived()); 277 | } 278 | 279 | /** 280 | * Return the capital gains tax amount for the selected fiscal year. 281 | * 282 | * @return float 283 | */ 284 | public function getCapitalGainsTax($allowNegative = false) { 285 | if (!$this->get51kThresholdExceeded() || ($this->getCapitalGains() < 0 && !$allowNegative)) { 286 | return 0.0; 287 | } 288 | 289 | return $this->getCapitalGains() * self::CAPITAL_GAINS_TAX_RATE; 290 | } 291 | 292 | public function getInterests($type = EarningsBag::INTEREST_RL) { 293 | return $this->earningsBag->getInterests($type); 294 | } 295 | 296 | /** 297 | * Filing the Modello Redditi is required if at least one section is required. 298 | * 299 | * @return boolean 300 | */ 301 | public function shouldFillModelloRedditi() { 302 | return $this->shouldFillRW() || $this->shouldFillRT() || $this->shouldFillRM(); 303 | } 304 | 305 | /** 306 | * Filling the RW section is required if you owned cryptocurrencies in the fiscal year. 307 | * 308 | * @return boolean 309 | */ 310 | public function shouldFillRW() { 311 | return $this->cryptoInfoBag->getTotalValues()['average_value'] > 0.01; 312 | } 313 | 314 | /** 315 | * Filling the RT section is required if you have to pay taxes on capital gains. 316 | * 317 | * @return boolean 318 | */ 319 | public function shouldFillRT() { 320 | return $this->get51kThresholdExceeded() && $this->getCapitalGains() !== 0.0; 321 | } 322 | 323 | /** 324 | * Filling the RM section is required if you have earned RM-like interests. 325 | * 326 | * @return boolean 327 | */ 328 | public function shouldFillRM() { 329 | return $this->getInterests(EarningsBag::INTEREST_RM) > 0.0; 330 | } 331 | 332 | /** 333 | * Filling the RL section is required if you have earned RL-like interests. 334 | * 335 | * @return boolean 336 | */ 337 | public function shouldFillRL() { 338 | return $this->getInterests(EarningsBag::INTEREST_RL) > 0.0; 339 | } 340 | 341 | /** 342 | * Get the summary of the report. 343 | * 344 | * @return array 345 | */ 346 | public function getSummary($rawValues = false) { 347 | return NumberUtils::recursiveFormatNumbers([ 348 | 'current_year_investment' => $this->currentYearInvestment, 349 | 'current_year_purchase_cost' => $this->currentYearPurchaseCost, 350 | 'current_year_income' => $this->currentYearIncome, 351 | 'capital_gains' => $this->getCapitalGains(true), 352 | 'capital_gains_and_airdrop' => $this->getCapitalGains(), 353 | 'capital_gains_tax' => $this->getCapitalGainsTax(), 354 | 'interests_rm' => $this->earningsBag->getInterests(EarningsBag::INTEREST_RM), 355 | 'interests_rm_tax' => $this->earningsBag->getInterests(EarningsBag::INTEREST_RM) * self::INTERESTS_EARNING_TAX_RATE, 356 | 'interests_rl' => $this->earningsBag->getInterests(EarningsBag::INTEREST_RL), 357 | 'total_values' => $this->cryptoInfoBag->getTotalValues() 358 | ], 2, false, $rawValues) + [ 359 | 'interests_rl_raw' => $this->earningsBag->getInterests(EarningsBag::INTEREST_RL), 360 | 'no_tax_area_threshold_exceeded' => $this->get51kThresholdExceeded(), 361 | 'should_fill_modello_redditi' => $this->shouldFillRL() || $this->shouldFillRW() || $this->shouldFillRT() || $this->shouldFillRM(), 362 | 'modello_redditi_available' => TemplatesManager::isTemplateAvailable($this->getCurrentYear()), 363 | 'should_fill_f24' => $this->shouldFillRT() || $this->shouldFillRM(), 364 | 'interest_exchanges' => array_values($this->earningsBag->getExchangeInterestList()), 365 | 'has_earnings_or_expenses' => $this->hasEarningsOrExpenses 366 | ]; 367 | } 368 | 369 | /** 370 | * Get the info for generate the report file. 371 | * 372 | * @return array 373 | */ 374 | public function getInfoForRender() { 375 | return [ 376 | 'fiscal_year' => $this->fiscalYear, 377 | 'summary' => $this->getSummary(), 378 | 'crypto_info' => $this->cryptoInfoBag->getInfoForRender(), 379 | 'earnings_categories' => $this->earningsBag->getCategoriesForRender(), 380 | 'days_list' => DateUtils::getListDaysInYear($this->fiscalYear), 381 | 'exchange_interest_types' => $this->earningsBag->getExchangeInterestTypes() 382 | ] + NumberUtils::recursiveFormatNumbers([ 383 | 'earnings' => $this->earningsBag->getInfoForRender(), 384 | 'detailed_earnings' => $this->earningsBag->getDetailedInfoForRender(), 385 | ]) + NumberUtils::recursiveFormatNumbers([ 386 | 'daily_values_start_of_year' => $this->cryptoInfoBag->getDailyValuesStartOfYear(), 387 | 'daily_values_end_of_year' => $this->cryptoInfoBag->getDailyValuesEndOfYear(), 388 | 'daily_values_real' => $this->cryptoInfoBag->getDailyValues(), 389 | 'exchange_volumes' => $this->getExchangeVolumes() 390 | ], 2, true); 391 | } 392 | 393 | /** 394 | * Get the info for generate the "Modello Redditi" form. 395 | * 396 | * @return array 397 | */ 398 | public function getInfoForModelloRedditi() { 399 | return [ 400 | 'fiscal_year' => $this->fiscalYear, 401 | 'sections_required' => [ 402 | 'rl' => $this->shouldFillRL(), 403 | 'rw' => $this->shouldFillRW(), 404 | 'rt' => $this->shouldFillRT(), 405 | 'rm' => $this->shouldFillRM() 406 | ] 407 | ]; 408 | } 409 | 410 | /** 411 | * Get the info for generate the "Modello Redditi" section RW form. 412 | * 413 | * @return array 414 | */ 415 | public function getModelloRedditiSectionRwInfo($finalValueMethod = 'average_value') { 416 | if ($finalValueMethod === 'real_value') { 417 | $finalValue = $this->cryptoInfoBag->getTotalValues()['value_end_of_year']; 418 | } elseif ($finalValueMethod === 'real_value_more_incomes') { 419 | $finalValue = $this->cryptoInfoBag->getTotalValues()['value_end_of_year'] - min($this->currentYearInvestment, 0); 420 | } else { 421 | // fallback to average_value 422 | $finalValue = $this->cryptoInfoBag->getTotalValues()['average_value']; 423 | } 424 | 425 | return [ 426 | 'initial_value' => ( 427 | $this->fiscalYear === $this->getFirstYear() ? 428 | $this->transactionsBag->transactions[1]->value : 429 | $this->cryptoInfoBag->getTotalValues()['value_start_of_year'] 430 | ), 431 | 'final_value' => $finalValue 432 | ]; 433 | } 434 | 435 | /** 436 | * Set the report fiscal year and reset all the year related properties. 437 | * 438 | * @param integer $year 439 | */ 440 | private function setFiscalYear($year) { 441 | $this->fiscalYear = intval($year); 442 | 443 | if ($this->fiscalYear < $this->getFirstYear() || $this->fiscalYear > $this->getLastYear()) { 444 | throw new InvalidYearException($year); 445 | } 446 | 447 | $this->cryptoInfoBag = new CryptoInfoBag($this->transactionsBag->transactions, $this->fiscalYear); 448 | $this->earningsBag = new EarningsBag($this->exchangeInterestTypes); 449 | $this->currentYearInvestment = 0.0; 450 | $this->currentYearPurchaseCost = 0.0; 451 | $this->currentYearIncome = 0.0; 452 | $this->exchangeVolumes = []; 453 | $this->hasEarningsOrExpenses = false; 454 | 455 | \Spatie\Once\Cache::flush(); 456 | } 457 | 458 | /** 459 | * Increment the trading volume of an exchange. 460 | * 461 | * @param string $exchange 462 | * @param float $value 463 | * @return void 464 | */ 465 | private function incrementExchangeVolume($exchange, $value) { 466 | if (strlen($exchange) === 0) { 467 | return; 468 | } elseif (!isset($this->exchangeVolumes[$exchange])) { 469 | $this->exchangeVolumes[$exchange] = 0.0; 470 | } 471 | 472 | $this->exchangeVolumes[$exchange] += $value; 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc.