Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot]
01181ceea9 Merge pull request #10672 from firefly-iii/release-1753879077
🤖 Automatically merge the PR into the develop branch.
2025-07-30 14:38:08 +02:00
JC5
97643639d1 🤖 Auto commit for release 'develop' on 2025-07-30 2025-07-30 14:37:57 +02:00
James Cole
424783c47b Add convertToNative to cache key. 2025-07-30 14:33:26 +02:00
James Cole
ea0ced70b2 Clean up sankey. 2025-07-30 11:24:00 +02:00
James Cole
1a633e64ef Category chart will also convert. 2025-07-30 10:20:35 +02:00
James Cole
30da3f4399 Also include budget in currency conversion. 2025-07-30 09:59:52 +02:00
12 changed files with 396 additions and 280 deletions

View File

@@ -36,6 +36,7 @@ use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; use FireflyIII\Repositories\Budget\OperationsRepositoryInterface;
use FireflyIII\Support\Http\Api\CleansChartData; use FireflyIII\Support\Http\Api\CleansChartData;
use FireflyIII\Support\Http\Api\ExchangeRateConverter;
use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -110,25 +111,7 @@ class BudgetController extends Controller
{ {
// get all limits: // get all limits:
$limits = $this->blRepository->getBudgetLimits($budget, $start, $end); $limits = $this->blRepository->getBudgetLimits($budget, $start, $end);
// 'currency_id' => string '1' (length=1)
// 'currency_code' => string 'EUR' (length=3)
// 'currency_name' => string 'Euro' (length=4)
// 'currency_symbol' => string '€' (length=3)
// 'currency_decimal_places' => int 2
// 'start' => string '2025-07-01T00:00:00+02:00' (length=25)
// 'end' => string '2025-07-31T23:59:59+02:00' (length=25)
// 'budgeted' => string '100.000000000000' (length=16)
// 'spent' => string '-421.230000000000' (length=17)
// 'left' => string '0' (length=1)
// 'overspent' => string '321.230000000000' (length=16)
$rows = []; $rows = [];
// instead of using the budget limits as a thing to collect all expenses,
// use the budget range itself to collect and group them,
// AND THEN add budgeted amounts from the limits to the rows.
$spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget])); $spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget]));
$expenses = $this->processExpenses($budget->id, $spent, $start, $end); $expenses = $this->processExpenses($budget->id, $spent, $start, $end);
@@ -294,12 +277,36 @@ class BudgetController extends Controller
private function filterLimit(int $currencyId, Collection $limits): ?BudgetLimit private function filterLimit(int $currencyId, Collection $limits): ?BudgetLimit
{ {
foreach ($limits as $limit) { $amount = '0';
if ($limit->transaction_currency_id === $currencyId) { $limit = null;
return $limit; $converter = new ExchangeRateConverter();
/** @var BudgetLimit $current */
foreach ($limits as $current) {
if (true === $this->convertToNative) {
if ($current->transaction_currency_id === $this->nativeCurrency->id) {
// simply add it.
$amount = bcadd($amount, (string)$current->amount);
Log::debug(sprintf('Set amount in limit to %s', $amount));
}
if ($current->transaction_currency_id !== $this->nativeCurrency->id) {
// convert and then add it.
$converted = $converter->convert($current->transactionCurrency, $this->nativeCurrency, $limit->start_date, $limit->amount);
$amount = bcadd($amount, $converted);
Log::debug(sprintf('Budgeted in limit #%d: %s %s, converted to %s %s', $current->id, $current->transactionCurrency->code, $current->amount, $this->nativeCurrency->code, $converted));
Log::debug(sprintf('Set amount in limit to %s', $amount));
}
}
if ($current->transaction_currency_id === $currencyId) {
$limit = $current;
} }
} }
if (null !== $limit && true === $this->convertToNative) {
// convert and add all amounts.
$limit->amount = app('steam')->positive($amount);
Log::debug(sprintf('Final amount in limit with converted amount %s', $limit->amount));
}
return null; return $limit;
} }
} }

View File

@@ -25,17 +25,20 @@ declare(strict_types=1);
namespace FireflyIII\Api\V1\Controllers\Chart; namespace FireflyIII\Api\V1\Controllers\Chart;
use Carbon\Carbon; use Carbon\Carbon;
use FireflyIII\Api\V2\Controllers\Controller; use FireflyIII\Api\V1\Controllers\Controller;
use FireflyIII\Api\V2\Request\Generic\DateRequest; use FireflyIII\Api\V2\Request\Generic\DateRequest;
use FireflyIII\Enums\AccountTypeEnum; use FireflyIII\Enums\AccountTypeEnum;
use FireflyIII\Enums\TransactionTypeEnum; use FireflyIII\Enums\TransactionTypeEnum;
use FireflyIII\Enums\UserRoleEnum;
use FireflyIII\Exceptions\FireflyException; use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Helpers\Collector\GroupCollectorInterface; use FireflyIII\Helpers\Collector\GroupCollectorInterface;
use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Account\AccountRepositoryInterface;
use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface;
use FireflyIII\Support\Http\Api\CleansChartData; use FireflyIII\Support\Http\Api\CleansChartData;
use FireflyIII\Support\Http\Api\ExchangeRateConverter;
use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/** /**
* Class BudgetController * Class BudgetController
@@ -45,6 +48,8 @@ class CategoryController extends Controller
use CleansChartData; use CleansChartData;
use ValidatesUserGroupTrait; use ValidatesUserGroupTrait;
protected array $acceptedRoles = [UserRoleEnum::READ_ONLY];
private AccountRepositoryInterface $accountRepos; private AccountRepositoryInterface $accountRepos;
private CurrencyRepositoryInterface $currencyRepos; private CurrencyRepositoryInterface $currencyRepos;
@@ -79,9 +84,10 @@ class CategoryController extends Controller
/** @var Carbon $end */ /** @var Carbon $end */
$end = $this->parameters->get('end'); $end = $this->parameters->get('end');
$accounts = $this->accountRepos->getAccountsByType([AccountTypeEnum::DEBT->value, AccountTypeEnum::LOAN->value, AccountTypeEnum::MORTGAGE->value, AccountTypeEnum::ASSET->value, AccountTypeEnum::DEFAULT->value]); $accounts = $this->accountRepos->getAccountsByType([AccountTypeEnum::DEBT->value, AccountTypeEnum::LOAN->value, AccountTypeEnum::MORTGAGE->value, AccountTypeEnum::ASSET->value]);
$currencies = []; $currencies = [];
$return = []; $return = [];
$converter = new ExchangeRateConverter();
// get journals for entire period: // get journals for entire period:
/** @var GroupCollectorInterface $collector */ /** @var GroupCollectorInterface $collector */
@@ -93,20 +99,40 @@ class CategoryController extends Controller
/** @var array $journal */ /** @var array $journal */
foreach ($journals as $journal) { foreach ($journals as $journal) {
$currencyId = (int) $journal['currency_id']; // find journal:
$currency = $currencies[$currencyId] ?? $this->currencyRepos->find($currencyId); $journalCurrencyId = (int)$journal['currency_id'];
$currencies[$currencyId] = $currency; $currency = $currencies[$journalCurrencyId] ?? $this->currencyRepos->find($journalCurrencyId);
$categoryName = $journal['category_name'] ?? (string) trans('firefly.no_category'); $currencies[$journalCurrencyId] = $currency;
$amount = app('steam')->positive($journal['amount']); $currencyId = (int)$currency->id;
$key = sprintf('%s-%s', $categoryName, $currency->code); $currencyName = (string)$currency->name;
$currencyCode = (string)$currency->code;
$currencySymbol = (string)$currency->symbol;
$currencyDecimalPlaces = (int)$currency->decimal_places;
$amount = app('steam')->positive($journal['amount']);
// overrule if necessary:
if ($this->convertToNative && $journalCurrencyId !== $this->nativeCurrency->id) {
$currencyId = (int)$this->nativeCurrency->id;
$currencyName = (string)$this->nativeCurrency->name;
$currencyCode = (string)$this->nativeCurrency->code;
$currencySymbol = (string)$this->nativeCurrency->symbol;
$currencyDecimalPlaces = (int)$this->nativeCurrency->decimal_places;
$convertedAmount = $converter->convert($currency, $this->nativeCurrency, $journal['date'], $amount);
Log::debug(sprintf('Converted %s %s to %s %s', $journal['currency_code'], $amount, $this->nativeCurrency->code, $convertedAmount));
$amount = $convertedAmount;
}
$categoryName = $journal['category_name'] ?? (string)trans('firefly.no_category');
$key = sprintf('%s-%s', $categoryName, $currencyCode);
// create arrays // create arrays
$return[$key] ??= [ $return[$key] ??= [
'label' => $categoryName, 'label' => $categoryName,
'currency_id' => (string) $currency->id, 'currency_id' => (string)$currencyId,
'currency_code' => $currency->code, 'currency_code' => $currencyCode,
'currency_name' => $currency->name, 'currency_name' => $currencyName,
'currency_symbol' => $currency->symbol, 'currency_symbol' => $currencySymbol,
'currency_decimal_places' => $currency->decimal_places, 'currency_decimal_places' => $currencyDecimalPlaces,
'period' => null, 'period' => null,
'start' => $start->toAtomString(), 'start' => $start->toAtomString(),
'end' => $end->toAtomString(), 'end' => $end->toAtomString(),
@@ -114,12 +140,12 @@ class CategoryController extends Controller
]; ];
// add monies // add monies
$return[$key]['amount'] = bcadd($return[$key]['amount'], (string) $amount); $return[$key]['amount'] = bcadd($return[$key]['amount'], (string)$amount);
} }
$return = array_values($return); $return = array_values($return);
// order by amount // order by amount
usort($return, static fn (array $a, array $b) => (float) $a['amount'] < (float) $b['amount'] ? 1 : -1); usort($return, static fn (array $a, array $b) => (float)$a['amount'] < (float)$b['amount'] ? 1 : -1);
return response()->json($this->clean($return)); return response()->json($this->clean($return));
} }

View File

@@ -24,14 +24,16 @@ declare(strict_types=1);
namespace FireflyIII\Repositories\Budget; namespace FireflyIII\Repositories\Budget;
use Deprecated;
use Carbon\Carbon; use Carbon\Carbon;
use Deprecated;
use FireflyIII\Enums\TransactionTypeEnum; use FireflyIII\Enums\TransactionTypeEnum;
use FireflyIII\Helpers\Collector\GroupCollectorInterface; use FireflyIII\Helpers\Collector\GroupCollectorInterface;
use FireflyIII\Models\Account; use FireflyIII\Models\Account;
use FireflyIII\Models\Budget; use FireflyIII\Models\Budget;
use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Account\AccountRepositoryInterface;
use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Http\Api\ExchangeRateConverter;
use FireflyIII\Support\Report\Summarizer\TransactionSummarizer; use FireflyIII\Support\Report\Summarizer\TransactionSummarizer;
use FireflyIII\Support\Repositories\UserGroup\UserGroupInterface; use FireflyIII\Support\Repositories\UserGroup\UserGroupInterface;
use FireflyIII\Support\Repositories\UserGroup\UserGroupTrait; use FireflyIII\Support\Repositories\UserGroup\UserGroupTrait;
@@ -55,17 +57,17 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
$total = '0'; $total = '0';
$count = 0; $count = 0;
foreach ($budget->budgetlimits as $limit) { foreach ($budget->budgetlimits as $limit) {
$diff = (int) $limit->start_date->diffInDays($limit->end_date, true); $diff = (int)$limit->start_date->diffInDays($limit->end_date, true);
$diff = 0 === $diff ? 1 : $diff; $diff = 0 === $diff ? 1 : $diff;
$amount = $limit->amount; $amount = $limit->amount;
$perDay = bcdiv((string) $amount, (string) $diff); $perDay = bcdiv((string)$amount, (string)$diff);
$total = bcadd($total, $perDay); $total = bcadd($total, $perDay);
++$count; ++$count;
app('log')->debug(sprintf('Found %d budget limits. Per day is %s, total is %s', $count, $perDay, $total)); app('log')->debug(sprintf('Found %d budget limits. Per day is %s, total is %s', $count, $perDay, $total));
} }
$avg = $total; $avg = $total;
if ($count > 0) { if ($count > 0) {
$avg = bcdiv($total, (string) $count); $avg = bcdiv($total, (string)$count);
} }
app('log')->debug(sprintf('%s / %d = %s = average.', $total, $count, $avg)); app('log')->debug(sprintf('%s / %d = %s = average.', $total, $count, $avg));
@@ -93,9 +95,9 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
/** @var array $journal */ /** @var array $journal */
foreach ($journals as $journal) { foreach ($journals as $journal) {
// prep data array for currency: // prep data array for currency:
$budgetId = (int) $journal['budget_id']; $budgetId = (int)$journal['budget_id'];
$budgetName = $journal['budget_name']; $budgetName = $journal['budget_name'];
$currencyId = (int) $journal['currency_id']; $currencyId = (int)$journal['currency_id'];
$key = sprintf('%d-%d', $budgetId, $currencyId); $key = sprintf('%d-%d', $budgetId, $currencyId);
$data[$key] ??= [ $data[$key] ??= [
@@ -110,7 +112,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
'entries' => [], 'entries' => [],
]; ];
$date = $journal['date']->format($carbonFormat); $date = $journal['date']->format($carbonFormat);
$data[$key]['entries'][$date] = bcadd($data[$key]['entries'][$date] ?? '0', (string) $journal['amount']); $data[$key]['entries'][$date] = bcadd($data[$key]['entries'][$date] ?? '0', (string)$journal['amount']);
} }
return $data; return $data;
@@ -124,7 +126,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
public function listExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null): array public function listExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null): array
{ {
/** @var GroupCollectorInterface $collector */ /** @var GroupCollectorInterface $collector */
$collector = app(GroupCollectorInterface::class); $collector = app(GroupCollectorInterface::class);
$collector->setUser($this->user)->setRange($start, $end)->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $collector->setUser($this->user)->setRange($start, $end)->setTypes([TransactionTypeEnum::WITHDRAWAL->value]);
if ($accounts instanceof Collection && $accounts->count() > 0) { if ($accounts instanceof Collection && $accounts->count() > 0) {
$collector->setAccounts($accounts); $collector->setAccounts($accounts);
@@ -136,15 +138,41 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
$collector->setBudgets($this->getBudgets()); $collector->setBudgets($this->getBudgets());
} }
$collector->withBudgetInformation()->withAccountInformation()->withCategoryInformation(); $collector->withBudgetInformation()->withAccountInformation()->withCategoryInformation();
$journals = $collector->getExtractedJournals(); $journals = $collector->getExtractedJournals();
$array = []; $array = [];
// if needs conversion to native.
$convertToNative = Amount::convertToNative($this->user);
$nativeCurrency = Amount::getNativeCurrencyByUserGroup($this->userGroup);
$currencyId = (int) $nativeCurrency->id;
$currencyCode = $nativeCurrency->code;
$currencyName = $nativeCurrency->name;
$currencySymbol = $nativeCurrency->symbol;
$currencyDecimalPlaces = $nativeCurrency->decimal_places;
$converter = new ExchangeRateConverter();
$currencies = [
$currencyId => $nativeCurrency,
];
foreach ($journals as $journal) { foreach ($journals as $journal) {
$currencyId = (int) $journal['currency_id']; $amount = app('steam')->negative($journal['amount']);
$budgetId = (int) $journal['budget_id']; $journalCurrencyId = (int)$journal['currency_id'];
$budgetName = (string) $journal['budget_name']; if (false === $convertToNative) {
$currencyId = $journalCurrencyId;
$currencyName = $journal['currency_name'];
$currencySymbol = $journal['currency_symbol'];
$currencyCode = $journal['currency_code'];
$currencyDecimalPlaces = $journal['currency_decimal_places'];
}
if (true === $convertToNative && $journalCurrencyId !== $currencyId) {
$currencies[$journalCurrencyId] ??= TransactionCurrency::find($journalCurrencyId);
$amount = $converter->convert($currencies[$journalCurrencyId], $nativeCurrency, $journal['date'], $amount);
}
// catch "no category" entries. $budgetId = (int)$journal['budget_id'];
$budgetName = (string)$journal['budget_name'];
// catch "no budget" entries.
if (0 === $budgetId) { if (0 === $budgetId) {
continue; continue;
} }
@@ -153,10 +181,10 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
$array[$currencyId] ??= [ $array[$currencyId] ??= [
'budgets' => [], 'budgets' => [],
'currency_id' => $currencyId, 'currency_id' => $currencyId,
'currency_name' => $journal['currency_name'], 'currency_name' => $currencyName,
'currency_symbol' => $journal['currency_symbol'], 'currency_symbol' => $currencySymbol,
'currency_code' => $journal['currency_code'], 'currency_code' => $currencyCode,
'currency_decimal_places' => $journal['currency_decimal_places'], 'currency_decimal_places' => $currencyDecimalPlaces,
]; ];
// info about the categories: // info about the categories:
@@ -168,9 +196,9 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
// add journal to array: // add journal to array:
// only a subset of the fields. // only a subset of the fields.
$journalId = (int) $journal['transaction_journal_id']; $journalId = (int)$journal['transaction_journal_id'];
$array[$currencyId]['budgets'][$budgetId]['transaction_journals'][$journalId] = [ $array[$currencyId]['budgets'][$budgetId]['transaction_journals'][$journalId] = [
'amount' => app('steam')->negative($journal['amount']), 'amount' => $amount,
'destination_account_id' => $journal['destination_account_id'], 'destination_account_id' => $journal['destination_account_id'],
'destination_account_name' => $journal['destination_account_name'], 'destination_account_name' => $journal['destination_account_name'],
'source_account_id' => $journal['source_account_id'], 'source_account_id' => $journal['source_account_id'],

View File

@@ -79,7 +79,7 @@ return [
// see cer.php for exchange rates feature flag. // see cer.php for exchange rates feature flag.
], ],
'version' => 'develop/2025-07-30', 'version' => 'develop/2025-07-30',
'build_time' => 1753858473, 'build_time' => 1753878970,
'api_version' => '2.1.0', // field is no longer used. 'api_version' => '2.1.0', // field is no longer used.
'db_version' => 26, 'db_version' => 26,

View File

@@ -48,9 +48,19 @@ export default () => ({
} }
this.getFreshData(); this.getFreshData();
}, },
eventListeners: {
['@convert-to-native.window'](event){
console.log('I heard that! (dashboard/budgets)');
this.convertToNative = event.detail;
chartData = null;
this.loadChart();
}
},
drawChart(options) { drawChart(options) {
if (null !== chart) { if (null !== chart) {
chart.data.datasets = options.data.datasets; chart.data = options.data;
chart.update(); chart.update();
return; return;
} }
@@ -59,7 +69,7 @@ export default () => ({
getFreshData() { getFreshData() {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey('ds_bdg_chart', {start: start, end: end}); const cacheKey = getCacheKey('ds_bdg_chart', {convertToNative: this.convertToNative, start: start, end: end});
//const cacheValid = window.store.get('cacheValid'); //const cacheValid = window.store.get('cacheValid');
const cacheValid = false; const cacheValid = false;
let cachedData = window.store.get(cacheKey); let cachedData = window.store.get(cacheKey);

View File

@@ -33,6 +33,17 @@ let afterPromises = false;
export default () => ({ export default () => ({
loading: false, loading: false,
convertToNative: false, convertToNative: false,
eventListeners: {
['@convert-to-native.window'](event){
console.log('I heard that! (dashboard/categories)');
this.convertToNative = event.detail;
chartData = null;
this.loadChart();
}
},
generateOptions(data) { generateOptions(data) {
currencies = []; currencies = [];
let options = getDefaultChartSettings('column'); let options = getDefaultChartSettings('column');
@@ -43,11 +54,6 @@ export default () => ({
if (data.hasOwnProperty(i)) { if (data.hasOwnProperty(i)) {
let current = data[i]; let current = data[i];
let code = current.currency_code; let code = current.currency_code;
// only use native code when doing auto conversion.
if (this.convertToNative) {
code = current.native_currency_code;
}
if (!series.hasOwnProperty(code)) { if (!series.hasOwnProperty(code)) {
series[code] = { series[code] = {
name: code, name: code,
@@ -65,9 +71,6 @@ export default () => ({
let yAxis = 'y'; let yAxis = 'y';
let current = data[i]; let current = data[i];
let code = current.currency_code; let code = current.currency_code;
if (this.convertToNative) {
code = current.native_currency_code;
}
// loop series, add 0 if not present or add actual amount. // loop series, add 0 if not present or add actual amount.
for (const ii in series) { for (const ii in series) {
@@ -77,10 +80,6 @@ export default () => ({
// this series' currency matches this column's currency. // this series' currency matches this column's currency.
amount = parseFloat(current.amount); amount = parseFloat(current.amount);
yAxis = 'y' + current.currency_code; yAxis = 'y' + current.currency_code;
if (this.convertToNative) {
amount = parseFloat(current.native_amount);
yAxis = 'y' + current.native_currency_code;
}
} }
if (series[ii].data.hasOwnProperty(current.label)) { if (series[ii].data.hasOwnProperty(current.label)) {
// there is a value for this particular currency. The amount from this column will be added. // there is a value for this particular currency. The amount from this column will be added.
@@ -147,7 +146,7 @@ export default () => ({
getFreshData() { getFreshData() {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey('ds_ct_chart', {start: start, end: end}); const cacheKey = getCacheKey('ds_ct_chart', {convertToNative: this.convertToNative, start: start, end: end});
const cacheValid = window.store.get('cacheValid'); const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey); let cachedData = window.store.get(cacheKey);

View File

@@ -71,9 +71,10 @@ let index = function () {
return { return {
convertToNative: false, convertToNative: false,
saveNativeSettings(event) { saveNativeSettings(event) {
setVariable('convert_to_native', event.currentTarget.checked).then(() => { let target = event.currentTarget || event.target;
console.log('Set convert to native to: ', event.currentTarget.checked); setVariable('convert_to_native',target.checked).then(() => {
this.$dispatch('convert-to-native', event.currentTarget.checked); console.log('Set convert to native to: ', target.checked);
this.$dispatch('convert-to-native', target.checked);
}); });
}, },
init() { init() {

View File

@@ -36,7 +36,7 @@ export default () => ({
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
// needs user data. // needs user data.
const cacheKey = getCacheKey(PIGGY_CACHE_KEY, {start: start, end: end}); const cacheKey = getCacheKey(PIGGY_CACHE_KEY, {convertToNative: this.convertToNative, start: start, end: end});
const cacheValid = window.store.get('cacheValid'); const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey); let cachedData = window.store.get(cacheKey);

View File

@@ -29,17 +29,16 @@ import i18next from "i18next";
Chart.register({SankeyController, Flow}); Chart.register({SankeyController, Flow});
const SANKEY_CACHE_KEY = 'ds_sankey_data'; const SANKEY_CACHE_KEY = 'ds_sankey_data';
let currencies = []; let currencies = [];
let afterPromises = false; let afterPromises = false;
let chart = null; let chart = null;
let transactions = []; let transactions = [];
let convertToNative = false; let convertToNative = false;
let translations = { let translations = {
category: null, category: null,
unknown_category: null, unknown_category: null,
in: null, in: null,
out: null, out: null,
// TODO
unknown_source: null, unknown_source: null,
unknown_dest: null, unknown_dest: null,
unknown_account: null, unknown_account: null,
@@ -80,75 +79,97 @@ const getColor = function (key) {
// little helper // little helper
function getObjectName(type, name, direction, code) { function getObjectName(type, name, direction, code) {
if(convertToNative) {
return getObjectNameWithoutCurrency(type, name, direction);
}
return getObjectNameWithCurrency(type, name, direction, code);
// category 4x
if ('category' === type && null !== name && 'in' === direction) {
return translations.category + ' "' + name + '" (' + translations.in + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null === name && 'in' === direction) {
return translations.unknown_category + ' (' + translations.in + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null !== name && 'out' === direction) {
return translations.category + ' "' + name + '" (' + translations.out + (convertToNative ? ', ' + code + ')' : ')');
}
if ('category' === type && null === name && 'out' === direction) {
return translations.unknown_category + ' (' + translations.out + (convertToNative ? ', ' + code + ')' : ')');
}
// account 4x
if ('account' === type && null === name && 'in' === direction) {
return translations.unknown_source + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null !== name && 'in' === direction) {
return translations.revenue_account + '"' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null === name && 'out' === direction) {
return translations.unknown_dest + (convertToNative ? ' (' + code + ')' : '');
}
if ('account' === type && null !== name && 'out' === direction) {
return translations.expense_account + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
// budget 2x
if ('budget' === type && null !== name) {
return translations.budget + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : '');
}
if ('budget' === type && null === name) {
return translations.unknown_budget + (convertToNative ? ' (' + code + ')' : '');
}
console.error('Cannot handle: type:"' + type + '", dir: "' + direction + '"');
} }
function getLabelName(type, name, code) { function getObjectNameWithoutCurrency(type, name, direction) {
// category if('category' === type) {
if ('category' === type && null !== name) { let catName = null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
return translations.category + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : ''); let directionText = 'in' === direction ? translations.in : translations.out;
return catName + ' (' + directionText + ')';
} }
if ('category' === type && null === name) { if('account' === type) {
return translations.unknown_category + (convertToNative ? ' (' + code + ')' : ''); let accountName = null === name ? translations.unknown_account : name;
let directionText = 'in' === direction ? translations.in : translations.out;
let fullAccountName = 'in' === direction ? translations.revenue_account + ' "' + accountName + '"' : translations.expense_account + ' "' + accountName + '"';
return fullAccountName + ' (' + directionText + ')';
} }
// account if('budget' === type) {
if ('account' === type && null === name) { return null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"';
return translations.unknown_account + (convertToNative ? ' (' + code + ')' : '');
} }
if ('account' === type && null !== name) { console.error('[a] Cannot handle: type:"' + type + '", dir: "' + direction + '"');
return name + (convertToNative ? ' (' + code + ')' : ''); }
function getObjectNameWithCurrency(type, name, direction, code) {
if('category' === type) {
let catName = null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
let directionText = 'in' === direction ? translations.in : translations.out;
return catName + ' (' + directionText + ', ' + code + ')';
} }
if('account' === type) {
// budget 2x let accountName = null === name ? translations.unknown_account : name;
if ('budget' === type && null !== name) { let directionText = 'in' === direction ? translations.in : translations.out;
return translations.budget + ' "' + name + '"' + (convertToNative ? ' (' + code + ')' : ''); let fullAccountName = 'in' === direction ? translations.revenue_account + ' "' + accountName + '"' : translations.expense_account + ' "' + accountName + '"';
return fullAccountName + ' (' + directionText + ', ' + code + ')';
} }
if ('budget' === type && null === name) { if('budget' === type) {
return translations.unknown_budget + (convertToNative ? ' (' + code + ')' : ''); return (null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"') + ' (' + code + ')';
} }
console.error('Cannot handle: type:"' + type + '"'); console.error('[b] Cannot handle: type:"' + type + '", dir: "' + direction + '"');
} }
function getLabel(type, name, code) {
if(convertToNative) {
return getLabelWithoutCurrency(type, name);
}
return getLabelWithCurrency(type, name, code);
}
function getLabelWithoutCurrency(type, name) {
if('category' === type) {
return null === name ? translations.unknown_category : translations.category + ' "' + name + '"';
}
if('account' === type) {
return null === name ? translations.unknown_account : name;
}
if('budget' === type) {
return null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"';
}
console.error('[a] Cannot handle: type:"' + type + '"');
}
function getLabelWithCurrency(type, name, code) {
if('category' === type) {
return (null === name ? translations.unknown_category : translations.category + ' "' + name + '"') + ' ('+ code + ')';
}
if('account' === type) {
return (null === name ? translations.unknown_account : name) + ' (' + code + ')';
}
if('budget' === type) {
return (null === name ? translations.unknown_budget : translations.budget + ' "' + name + '"') + ' (' + code + ')';;
}
console.error('[b] Cannot handle: type:"' + type + '"');
}
export default () => ({ export default () => ({
loading: false, loading: false,
convertToNative: false, convertToNative: false,
processedData: null,
eventListeners: {
['@convert-to-native.window'](event){
console.log('I heard that! (dashboard/sankey)');
this.convertToNative = event.detail;
convertToNative = event.detail;
this.processedData = null;
this.loadChart();
}
},
generateOptions() { generateOptions() {
let options = getDefaultChartSettings('sankey'); let options = getDefaultChartSettings('sankey');
@@ -156,125 +177,22 @@ export default () => ({
currencies = []; currencies = [];
// variables collected for the sankey chart: // variables collected for the sankey chart:
let amounts = {}; this.parseTransactionGroups(transactions);
let labels = {};
for (let i in transactions) {
if (transactions.hasOwnProperty(i)) {
let group = transactions[i];
for (let ii in group.attributes.transactions) {
if (group.attributes.transactions.hasOwnProperty(ii)) {
// properties of the transaction, used in the generation of the chart:
let transaction = group.attributes.transactions[ii];
let currencyCode = this.convertToNative ? transaction.native_currency_code : transaction.currency_code;
if(this.convertToNative && (!transaction.hasOwnProperty('native_amount') || null === transaction.native_amount)) {
// skip this transaction, it has no native amount.
console.error('No native amount for transaction #' + group.id + ' ('+this.convertToNative+')');
continue;
}
let amount = this.convertToNative ? parseFloat(transaction.native_amount) : parseFloat(transaction.amount);
let flowKey;
/*
Two entries in the sankey diagram for deposits:
1. From the revenue account (source) to a category (in).
2. From the category (in) to the big inbox.
*/
if ('deposit' === transaction.type) {
// nr 1
let category = getObjectName('category', transaction.category_name, 'in', currencyCode);
let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode);
labels[category] = getLabelName('category', transaction.category_name, currencyCode);
labels[revenueAccount] = getLabelName('account', transaction.source_name, currencyCode);
flowKey = revenueAccount + '-' + category + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: revenueAccount,
to: category,
amount: 0
};
}
amounts[flowKey].amount += amount;
// nr 2
flowKey = category + '-' + translations.all_money + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: category,
to: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
amount: 0
};
}
amounts[flowKey].amount += amount;
}
/*
Three entries in the sankey diagram for withdrawals:
1. From the big box to a budget.
2. From a budget to a category.
3. From a category to an expense account.
*/
if ('withdrawal' === transaction.type) {
// 1.
let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
labels[budget] = getLabelName('budget', transaction.budget_name, currencyCode);
flowKey = translations.all_money + '-' + budget + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
to: budget,
amount: 0
};
}
amounts[flowKey].amount += amount;
// 2.
let category = getObjectName('category', transaction.category_name, 'out', currencyCode);
labels[category] = getLabelName('category', transaction.category_name, currencyCode);
flowKey = budget + '-' + category + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: budget,
to: category,
amount: 0
};
}
amounts[flowKey].amount += amount;
// 3.
let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode);
labels[expenseAccount] = getLabelName('account', transaction.destination_name, currencyCode);
flowKey = category + '-' + expenseAccount + '-' + currencyCode;
if (!amounts.hasOwnProperty(flowKey)) {
amounts[flowKey] = {
from: category,
to: expenseAccount,
amount: 0
};
}
amounts[flowKey].amount += amount;
}
}
}
}
}
let dataSet = let dataSet =
// sankey chart has one data set. // sankey chart has one data set.
{ {
label: 'Firefly III dashboard sankey chart', label: 'Firefly III dashboard sankey chart',
data: [], data: [],
colorFrom: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].from : ''), colorFrom: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].from : ''),
colorTo: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].to : ''), colorTo: (c) => getColor(c.dataset.data[c.dataIndex] ? c.dataset.data[c.dataIndex].to : ''),
colorMode: 'gradient', // or 'from' or 'to' colorMode: 'gradient', // or 'from' or 'to'
labels: labels, labels: this.processedData.labels,
size: 'min', // or 'min' if flow overlap is preferred size: 'min', // or 'min' if flow overlap is preferred
}; };
for (let i in amounts) { for (let i in this.processedData.amounts) {
if (amounts.hasOwnProperty(i)) { if (this.processedData.amounts.hasOwnProperty(i)) {
let amount = amounts[i]; let amount = this.processedData.amounts[i];
dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount}); dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount});
} }
} }
@@ -282,6 +200,133 @@ export default () => ({
return options; return options;
}, },
parseTransactionGroups(groups) {
this.processedData = {
amounts: {},
labels: {}
};
for (let i in groups) {
if (groups.hasOwnProperty(i)) {
let group = groups[i];
this.parseTransactionGroup(group);
}
}
},
parseTransactionGroup(group) {
for (let ii in group.attributes.transactions) {
if (group.attributes.transactions.hasOwnProperty(ii)) {
// properties of the transaction, used in the generation of the chart:
let transaction = group.attributes.transactions[ii];
this.parseTransaction(transaction);
}
}
},
parseTransaction(transaction) {
let currencyCode = transaction.currency_code;
let amount = parseFloat(transaction.amount);
let flowKey;
if (this.convertToNative) {
currencyCode = transaction.native_currency_code;
amount = parseFloat(transaction.native_amount);
}
if ('deposit' === transaction.type) {
this.parseDeposit(transaction, currencyCode, amount);
return;
}
if ('withdrawal' === transaction.type) {
this.parseWithdrawal(transaction, currencyCode, amount);
}
},
parseWithdrawal(transaction, currencyCode, amount) {
/*
Three entries in the sankey diagram for withdrawals:
1. From the big box to a budget.
2. From a budget to a category.
3. From a category to an expense account.
*/
// first one:
let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode);
this.processedData.labels[budget] = getLabel('budget', transaction.budget_name, currencyCode);
let flowKey = translations.all_money + '-' + budget + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
to: budget,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// second one:
let category = getObjectName('category', transaction.category_name, 'out', currencyCode);
this.processedData.labels[category] = getLabel('category', transaction.category_name, currencyCode);
flowKey = budget + '-' + category + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: budget,
to: category,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// third one:
let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode);
this.processedData.labels[expenseAccount] = getLabel('account', transaction.destination_name, currencyCode);
flowKey = category + '-' + expenseAccount + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: category,
to: expenseAccount,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
},
parseDeposit(transaction, currencyCode, amount) {
/*
Two entries in the sankey diagram for deposits:
1. From the revenue account (source) to a category (in).
2. From the category (in) to the big inbox.
*/
// this is the first one:
let category = getObjectName('category', transaction.category_name, 'in', currencyCode);
let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode);
let flowKey = revenueAccount + '-' + category + '-' + currencyCode;
this.processedData.labels[category] = getLabel('category', transaction.category_name, currencyCode);
this.processedData.labels[revenueAccount] = getLabel('account', transaction.source_name, currencyCode);
// create if necessary:
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: revenueAccount,
to: category,
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
// this is the second one:
flowKey = category + '-' + translations.all_money + '-' + currencyCode;
if (!this.processedData.amounts.hasOwnProperty(flowKey)) {
this.processedData.amounts[flowKey] = {
from: category,
to: translations.all_money + (this.convertToNative ? ' (' + currencyCode + ')' : ''),
amount: 0
};
}
this.processedData.amounts[flowKey].amount += amount;
},
drawChart(options) { drawChart(options) {
if (null !== chart) { if (null !== chart) {
chart.data.datasets = options.data.datasets; chart.data.datasets = options.data.datasets;
@@ -292,12 +337,12 @@ export default () => ({
}, },
getFreshData() { getFreshData() {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end}); const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {start: start, end: end});
const cacheValid = window.store.get('cacheValid'); const cacheValid = window.store.get('cacheValid');
let cachedData = window.store.get(cacheKey); let cachedData = window.store.get(cacheKey);
if (cacheValid && typeof cachedData !== 'undefined') { if (cacheValid && typeof cachedData !== 'undefined') {
transactions = cachedData; transactions = cachedData;
@@ -316,8 +361,8 @@ export default () => ({
this.downloadTransactions(params); this.downloadTransactions(params);
}, },
downloadTransactions(params) { downloadTransactions(params) {
const start = new Date(window.store.get('start')); const start = new Date(window.store.get('start'));
const end = new Date(window.store.get('end')); const end = new Date(window.store.get('end'));
const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {convertToNative: this.convertToNative, start: start, end: end}); const cacheKey = getCacheKey(SANKEY_CACHE_KEY, {convertToNative: this.convertToNative, start: start, end: end});
//console.log('Downloading page ' + params.page + '...'); //console.log('Downloading page ' + params.page + '...');
@@ -356,25 +401,25 @@ export default () => ({
transactions = []; transactions = [];
Promise.all([getVariable('convert_to_native', false)]).then((values) => { Promise.all([getVariable('convert_to_native', false)]).then((values) => {
this.convertToNative = values[0]; this.convertToNative = values[0];
convertToNative = values[0]; convertToNative = values[0];
// some translations: // some translations:
translations.all_money = i18next.t('firefly.all_money'); translations.all_money = i18next.t('firefly.all_money');
translations.category = i18next.t('firefly.category'); translations.category = i18next.t('firefly.category');
translations.in = i18next.t('firefly.money_flowing_in'); translations.in = i18next.t('firefly.money_flowing_in');
translations.out = i18next.t('firefly.money_flowing_out'); translations.out = i18next.t('firefly.money_flowing_out');
translations.unknown_category = i18next.t('firefly.unknown_category_plain'); translations.unknown_category = i18next.t('firefly.unknown_category_plain');
translations.unknown_source = i18next.t('firefly.unknown_source_plain'); translations.unknown_source = i18next.t('firefly.unknown_source_plain');
translations.unknown_dest = i18next.t('firefly.unknown_dest_plain'); translations.unknown_dest = i18next.t('firefly.unknown_dest_plain');
translations.unknown_account = i18next.t('firefly.unknown_any_plain'); translations.unknown_account = i18next.t('firefly.unknown_any_plain');
translations.unknown_budget = i18next.t('firefly.unknown_budget_plain'); translations.unknown_budget = i18next.t('firefly.unknown_budget_plain');
translations.expense_account = i18next.t('firefly.expense_account'); translations.expense_account = i18next.t('firefly.expense_account');
translations.revenue_account = i18next.t('firefly.revenue_account'); translations.revenue_account = i18next.t('firefly.revenue_account');
translations.budget = i18next.t('firefly.budget'); translations.budget = i18next.t('firefly.budget');
// console.log('sankey after promises'); // console.log('sankey after promises');
afterPromises = true; afterPromises = true;
this.loadChart(); this.loadChart();
}); });
window.store.observe('end', () => { window.store.observe('end', () => {

View File

@@ -1,4 +1,4 @@
<div class="row mb-2" x-data="budgets"> <div class="row mb-2" x-data="budgets" x-bind="eventListeners">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">

View File

@@ -1,4 +1,4 @@
<div class="row mb-2" x-data="categories"> <div class="row mb-2" x-data="categories" x-bind="eventListeners">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">

View File

@@ -6,7 +6,7 @@
>{{ __('firefly.income_and_expense') }}</a> >{{ __('firefly.income_and_expense') }}</a>
</h3> </h3>
</div> </div>
<div class="card-body" x-data="sankey"> <div class="card-body" x-data="sankey" x-bind="eventListeners">
<canvas id="sankey-chart"></canvas> <canvas id="sankey-chart"></canvas>
</div> </div>
</div> </div>