Various code for currency exchange rate support

This commit is contained in:
James Cole 2022-06-06 14:40:19 +02:00
parent 9c08b9f1d3
commit d007db166a
No known key found for this signature in database
GPG Key ID: B49A324B7EAD6D80
24 changed files with 987 additions and 188 deletions

View File

@ -0,0 +1,78 @@
<?php
/*
* SumController.php
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Api\V2\Controllers\Model\Bill;
use FireflyIII\Api\V2\Controllers\Controller;
use FireflyIII\Api\V2\Request\Generic\DateRequest;
use FireflyIII\Repositories\Bill\BillRepositoryInterface;
use FireflyIII\Support\Http\Api\ConvertsExchangeRates;
use Illuminate\Http\JsonResponse;
/**
* Class SumController
*/
class SumController extends Controller
{
private BillRepositoryInterface $repository;
use ConvertsExchangeRates;
/**
*
*/
public function __construct()
{
$this->middleware(
function ($request, $next) {
$this->repository = app(BillRepositoryInterface::class);
return $next($request);
}
);
}
/**
* @param DateRequest $request
* @return JsonResponse
*/
public function unpaid(DateRequest $request): JsonResponse
{
$dates = $request->getAll();
$result = $this->repository->sumUnpaidInRange($dates['start'], $dates['end']);
$converted = $this->cerSum($result);
// convert to JSON response:
return response()->json($converted);
}
/**
* @param DateRequest $request
* @return JsonResponse
*/
public function paid(DateRequest $request): JsonResponse
{
$dates = $request->getAll();
$result = $this->repository->sumPaidInRange($dates['start'], $dates['end']);
$converted = $this->cerSum($result);
// convert to JSON response:
return response()->json($converted);
}
}

View File

@ -0,0 +1,65 @@
<?php
/*
* SumController.php
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Api\V2\Controllers\Model\Budget;
use FireflyIII\Api\V2\Controllers\Controller;
use FireflyIII\Api\V2\Request\Generic\DateRequest;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Support\Http\Api\ConvertsExchangeRates;
use Illuminate\Http\JsonResponse;
/**
* Class SumController
*/
class SumController extends Controller
{
use ConvertsExchangeRates;
private BudgetRepositoryInterface $repository;
/**
*
*/
public function __construct()
{
$this->middleware(
function ($request, $next) {
$this->repository = app(BudgetRepositoryInterface::class);
return $next($request);
}
);
}
/**
* @param DateRequest $request
* @return JsonResponse
*/
public function budgeted(DateRequest $request): JsonResponse
{
$data = $request->getAll();
$result = $this->repository->budgetedInPeriod($data['start'], $data['end']);
$converted = $this->cerSum(array_values($result));
return response()->json($converted);
}
}

View File

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace FireflyIII\Handlers\Events;
use Carbon\Carbon;
use Database\Seeders\ExchangeRateSeeder;
use Exception;
use FireflyIII\Events\ActuallyLoggedIn;
use FireflyIII\Events\DetectedNewIPAddress;
@ -75,6 +76,17 @@ class UserEventHandler
return true;
}
/**
* @param RegisteredUser $event
* @return bool
*/
public function createExchangeRates(RegisteredUser $event): bool {
$seeder = new ExchangeRateSeeder;
$seeder->run();
return true;
}
/**
* Fires to see if a user is admin.
*

View File

@ -24,47 +24,17 @@ namespace FireflyIII\Models;
use Eloquent;
use FireflyIII\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* FireflyIII\Models\CurrencyExchangeRate
*
* @property int $id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string|null $deleted_at
* @property int $user_id
* @property int $from_currency_id
* @property int $to_currency_id
* @property Carbon $date
* @property string $rate
* @property string|null $user_rate
* @property-read TransactionCurrency $fromCurrency
* @property-read TransactionCurrency $toCurrency
* @property-read User $user
* @method static Builder|CurrencyExchangeRate newModelQuery()
* @method static Builder|CurrencyExchangeRate newQuery()
* @method static Builder|CurrencyExchangeRate query()
* @method static Builder|CurrencyExchangeRate whereCreatedAt($value)
* @method static Builder|CurrencyExchangeRate whereDate($value)
* @method static Builder|CurrencyExchangeRate whereDeletedAt($value)
* @method static Builder|CurrencyExchangeRate whereFromCurrencyId($value)
* @method static Builder|CurrencyExchangeRate whereId($value)
* @method static Builder|CurrencyExchangeRate whereRate($value)
* @method static Builder|CurrencyExchangeRate whereToCurrencyId($value)
* @method static Builder|CurrencyExchangeRate whereUpdatedAt($value)
* @method static Builder|CurrencyExchangeRate whereUserId($value)
* @method static Builder|CurrencyExchangeRate whereUserRate($value)
* @mixin Eloquent
* Class CurrencyExchangeRate
*/
class CurrencyExchangeRate extends Model
{
/** @var array Convert these fields to other data types */
protected $casts
= [
= [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'user_id' => 'int',
@ -72,6 +42,7 @@ class CurrencyExchangeRate extends Model
'to_currency_id' => 'int',
'date' => 'datetime',
];
protected $fillable = ['user_id', 'from_currency_id', 'to_currency_id', 'date', 'rate'];
/**
* @codeCoverageIgnore

View File

@ -83,6 +83,9 @@ class Preference extends Model
$user = auth()->user();
/** @var Preference|null $preference */
$preference = $user->preferences()->where('name', $value)->first();
if (null === $preference) {
$preference = $user->preferences()->where('id', (int) $value)->first();
}
if (null !== $preference) {
return $preference;
}

View File

@ -69,6 +69,7 @@ class EventServiceProvider extends ServiceProvider
'FireflyIII\Handlers\Events\UserEventHandler@sendRegistrationMail',
'FireflyIII\Handlers\Events\UserEventHandler@attachUserRole',
'FireflyIII\Handlers\Events\UserEventHandler@createGroupMembership',
'FireflyIII\Handlers\Events\UserEventHandler@createExchangeRates',
],
// is a User related event.
Login::class => [

View File

@ -33,6 +33,7 @@ use FireflyIII\Models\TransactionCurrency;
use FireflyIII\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use JsonException;
use Log;
/**
@ -304,7 +305,7 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface
*
* @return BudgetLimit
* @throws FireflyException
* @throws \JsonException
* @throws JsonException
*/
public function store(array $data): BudgetLimit
{
@ -354,7 +355,7 @@ class BudgetLimitRepository implements BudgetLimitRepositoryInterface
*
* @return BudgetLimit
* @throws FireflyException
* @throws \JsonException
* @throws JsonException
*/
public function update(BudgetLimit $budgetLimit, array $data): BudgetLimit
{

View File

@ -39,6 +39,7 @@ use FireflyIII\Services\Internal\Destroy\BudgetDestroyService;
use FireflyIII\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use JsonException;
use Log;
use Storage;
@ -80,6 +81,60 @@ class BudgetRepository implements BudgetRepositoryInterface
return $search->take($limit)->get();
}
/**
* @inheritDoc
*/
public function budgetedInPeriod(Carbon $start, Carbon $end): array
{
Log::debug(sprintf('Now in budgetedInPeriod("%s", "%s")', $start->format('Y-m-d'), $end->format('Y-m-d')));
$return = [];
/** @var BudgetLimitRepository $limitRepository */
$limitRepository = app(BudgetLimitRepository::class);
$limitRepository->setUser($this->user);
$budgets = $this->getActiveBudgets();
/** @var Budget $budget */
foreach ($budgets as $budget) {
Log::debug(sprintf('Budget #%d: "%s"', $budget->id, $budget->name));
$limits = $limitRepository->getBudgetLimits($budget, $start, $end);
/** @var BudgetLimit $limit */
foreach ($limits as $limit) {
Log::debug(sprintf('Budget limit #%d', $limit->id));
$currency = $limit->transactionCurrency;
$return[$currency->id] = $return[$currency->id] ?? [
'id' => (string) $currency->id,
'name' => $currency->name,
'symbol' => $currency->symbol,
'code' => $currency->code,
'decimal_places' => $currency->decimal_places,
'sum' => '0',
];
// same period
if ($limit->start_date->isSameDay($start) && $limit->end_date->isSameDay($end)) {
$return[$currency->id]['sum'] = bcadd($return[$currency->id]['sum'], (string) $limit->amount);
Log::debug(sprintf('Add full amount [1]: %s', $limit->amount));
continue;
}
// limit is inside of date range
if ($start->lte($limit->start_date) && $end->gte($limit->end_date)) {
$return[$currency->id]['sum'] = bcadd($return[$currency->id]['sum'], (string) $limit->amount);
Log::debug(sprintf('Add full amount [2]: %s', $limit->amount));
continue;
}
$total = $limit->start_date->diffInDays($limit->end_date) + 1; // include the day itself.
$days = $this->daysInOverlap($limit, $start, $end);
$amount = bcmul(bcdiv((string) $limit->amount, (string) $total), (string) $days);
$return[$currency->id]['sum'] = bcadd($return[$currency->id]['sum'], $amount);
Log::debug(sprintf('Amount per day: %s (%s over %d days). Total amount for %d days: %s',
bcdiv((string) $limit->amount, (string) $total),
$limit->amount,
$total,
$days,
$amount));
}
}
return $return;
}
/**
* @return bool
*/
@ -335,7 +390,7 @@ class BudgetRepository implements BudgetRepositoryInterface
*
* @return Budget
* @throws FireflyException
* @throws \JsonException
* @throws JsonException
*/
public function store(array $data): Budget
{
@ -551,7 +606,7 @@ class BudgetRepository implements BudgetRepositoryInterface
* @param Budget $budget
* @param array $data
* @throws FireflyException
* @throws \JsonException
* @throws JsonException
*/
private function updateAutoBudget(Budget $budget, array $data): void
{
@ -597,4 +652,42 @@ class BudgetRepository implements BudgetRepositoryInterface
$autoBudget->save();
}
/**
* How many days of this budget limit are between start and end?
*
* @param BudgetLimit $limit
* @param Carbon $start
* @param Carbon $end
* @return int
*/
private function daysInOverlap(BudgetLimit $limit, Carbon $start, Carbon $end): int
{
// start1 = $start
// start2 = $limit->start_date
// start1 = $end
// start2 = $limit->end_date
// limit is larger than start and end (inclusive)
// |-----------|
// |----------------|
if ($start->gte($limit->start_date) && $end->lte($limit->end_date)) {
return $start->diffInDays($end) + 1; // add one day
}
// limit starts earlier and limit ends first:
// |-----------|
// |-------|
if ($limit->start_date->lte($start) && $limit->end_date->lte($end)) {
// return days in the range $start-$limit_end
return $start->diffInDays($limit->end_date) + 1; // add one day, the day itself
}
// limit starts later and limit ends earlier
// |-----------|
// |-------|
if ($limit->start_date->gte($start) && $limit->end_date->gte($end)) {
// return days in the range $limit_start - $end
return $limit->start_date->diffInDays($end) + 1; // add one day, the day itself
}
return 0;
}
}

View File

@ -34,6 +34,7 @@ use Illuminate\Support\Collection;
*/
interface BudgetRepositoryInterface
{
/**
* @param string $query
* @param int $limit
@ -50,6 +51,15 @@ interface BudgetRepositoryInterface
*/
public function budgetStartsWith(string $query, int $limit): Collection;
/**
* Returns the amount that is budgeted in a period.
*
* @param Carbon $start
* @param Carbon $end
* @return array
*/
public function budgetedInPeriod(Carbon $start, Carbon $end): array;
/**
* @return bool
*/

View File

@ -0,0 +1,202 @@
<?php
/*
* ConvertsExchangeRates.php
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Support\Http\Api;
use Carbon\Carbon;
use FireflyIII\Models\CurrencyExchangeRate;
use FireflyIII\Models\TransactionCurrency;
use Log;
/**
* Trait ConvertsExchangeRates
*/
trait ConvertsExchangeRates
{
/**
* For a sum of entries, get the exchange rate to the native currency of
* the user.
* @param array $entries
* @return array
*/
public function cerSum(array $entries): array
{
/** @var TransactionCurrency $native */
$native = app('amount')->getDefaultCurrency();
$return = [];
/** @var array $entry */
foreach ($entries as $entry) {
$currency = $this->getCurrency((int) $entry['id']);
if ($currency->id !== $native->id) {
$amount = $this->convertAmount($entry['sum'], $currency, $native);
$entry['native_sum'] = $amount;
$entry['native_id'] = (string) $native->id;
$entry['native_name'] = $native->name;
$entry['native_symbol'] = $native->symbol;
$entry['native_code'] = $native->code;
$entry['native_decimal_places'] = $native->decimal_places;
}
if ($currency->id === $native->id) {
$entry['native_sum'] = $entry['sum'];
$entry['native_id'] = (string) $native->id;
$entry['native_name'] = $native->name;
$entry['native_symbol'] = $native->symbol;
$entry['native_code'] = $native->code;
$entry['native_decimal_places'] = $native->decimal_places;
}
$return[] = $entry;
}
return $return;
}
/**
* @param int $currencyId
* @return TransactionCurrency
*/
private function getCurrency(int $currencyId): TransactionCurrency
{
$result = TransactionCurrency::find($currencyId);
if (null === $result) {
return app('amount')->getDefaultCurrency();
}
return $result;
}
/**
* @param string $amount
* @param TransactionCurrency $from
* @param TransactionCurrency $to
* @return string
*/
private function convertAmount(string $amount, TransactionCurrency $from, TransactionCurrency $to, ?Carbon $date = null): string
{
Log::debug(sprintf('Converting %s from %s to %s', $amount, $from->code, $to->code));
$date = $date ?? Carbon::now();
$rate = $this->getRate($from, $to, $date);
return bcmul($amount, $rate);
}
/**
* @param TransactionCurrency $from
* @param TransactionCurrency $to
* @param Carbon $date
* @return string
*/
private function getRate(TransactionCurrency $from, TransactionCurrency $to, Carbon $date): string
{
Log::debug(sprintf('getRate(%s, %s, "%s")', $from->code, $to->code, $date->format('Y-m-d')));
/** @var CurrencyExchangeRate $result */
$result = auth()->user()
->currencyExchangeRates()
->where('from_currency_id', $from->id)
->where('to_currency_id', $to->id)
->where('date', '<=', $date->format('Y-m-d'))
->orderBy('date', 'DESC')
->first();
if (null !== $result) {
$rate = (string) $result->rate;
Log::debug(sprintf('Rate is %s', $rate));
return $rate;
}
// no result. perhaps the other way around?
/** @var CurrencyExchangeRate $result */
$result = auth()->user()
->currencyExchangeRates()
->where('from_currency_id', $to->id)
->where('to_currency_id', $from->id)
->where('date', '<=', $date->format('Y-m-d'))
->orderBy('date', 'DESC')
->first();
if (null !== $result) {
$rate = bcdiv('1', (string) $result->rate);
Log::debug(sprintf('Reversed rate is %s', $rate));
return $rate;
}
// try euro rates
$result1 = $this->getEuroRate($from, $date);
if ('0' === $result1) {
Log::debug(sprintf('No exchange rate between EUR and %s', $from->code));
return '0';
}
$result2 = $this->getEuroRate($to, $date);
if ('0' === $result2) {
Log::debug(sprintf('No exchange rate between EUR and %s', $to->code));
return '0';
}
// still need to inverse rate 2:
$result2 = bcdiv('1', $result2);
$rate = bcmul($result1, $result2);
Log::debug(sprintf('Rate %s to EUR is %s', $from->code, $result1));
Log::debug(sprintf('Rate EUR to %s is %s', $to->code, $result2));
Log::debug(sprintf('Rate for %s to %s is %s', $from->code, $to->code, $rate));
return $rate;
}
/**
* @param TransactionCurrency $currency
* @param Carbon $date
* @return string
*/
private function getEuroRate(TransactionCurrency $currency, Carbon $date): string
{
Log::debug(sprintf('Find rate for %s to Euro', $currency->code));
$euro = TransactionCurrency::whereCode('EUR')->first();
if (null === $euro) {
Log::warning('Cannot do indirect conversion without EUR.');
return '0';
}
// try one way:
/** @var CurrencyExchangeRate $result */
$result = auth()->user()
->currencyExchangeRates()
->where('from_currency_id', $currency->id)
->where('to_currency_id', $euro->id)
->where('date', '<=', $date->format('Y-m-d'))
->orderBy('date', 'DESC')
->first();
if (null !== $result) {
$rate = (string) $result->rate;
Log::debug(sprintf('Rate for %s to EUR is %s.', $currency->code, $rate));
return $rate;
}
// try the other way around and inverse it.
/** @var CurrencyExchangeRate $result */
$result = auth()->user()
->currencyExchangeRates()
->where('from_currency_id', $euro->id)
->where('to_currency_id', $currency->id)
->where('date', '<=', $date->format('Y-m-d'))
->orderBy('date', 'DESC')
->first();
if (null !== $result) {
$rate = bcdiv('1', (string) $result->rate);
Log::debug(sprintf('Inverted rate for %s to EUR is %s.', $currency->code, $rate));
return $rate;
}
Log::debug(sprintf('No rate for %s to EUR.', $currency->code));
return '0';
}
}

68
config/cer.php Normal file
View File

@ -0,0 +1,68 @@
<?php
/*
* default_cer.php
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
return [
// if currencies are added, default rates must be added as well!
// last exchange rate update: 6-6-2022
// source: https://www.xe.com/currencyconverter/
'date' => '2022-06-06',
'rates' => [
// europa
['EUR', 'HUF', 387.9629],
['EUR', 'GBP', 0.85420754],
['EUR', 'UAH', 31.659752],
['EUR', 'PLN', 4.581788],
['EUR', 'TRY', 17.801397],
['EUR', 'DKK', 7.4389753],
// Americas
['EUR', 'USD', 1.0722281],
['EUR', 'BRL', 5.0973173],
['EUR', 'CAD', 1.3459969],
['EUR', 'MXN', 20.899824],
// Oceania currencies
['EUR', 'IDR', 15466.299],
['EUR', 'AUD', 1.4838549],
['EUR', 'NZD', 1.6425829],
// africa
['EUR', 'EGP', 19.99735],
['EUR', 'MAD', 10.573307],
['EUR', 'ZAR', 16.413167],
// asia
['EUR', 'JPY', 140.15257],
['EUR', 'RMB', 7.1194265],
['EUR', 'RUB', 66.000895],
['EUR', 'INR', 83.220481],
// int
['EUR', 'XBT', 0, 00003417],
['EUR', 'BCH', 0.00573987],
['EUR', 'ETH', 0, 00056204],
['EUR', 'ILS', 3.5712508],
['EUR', 'CHF', 1.0323891],
['EUR', 'HRK', 7.5220845],
],
];

View File

@ -41,5 +41,6 @@ class DatabaseSeeder extends Seeder
$this->call(LinkTypeSeeder::class);
$this->call(ConfigSeeder::class);
$this->call(UserRoleSeeder::class);
$this->call(ExchangeRateSeeder::class);
}
}

View File

@ -0,0 +1,117 @@
<?php
/*
* ExchangeRateSeeder.php
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace Database\Seeders;
use FireflyIII\Models\CurrencyExchangeRate;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Log;
/**
* Class ExchangeRateSeeder
*/
class ExchangeRateSeeder extends Seeder
{
private Collection $users;
/**
* @return void
*/
public function run(): void
{
$count = User::count();
if (0 === $count) {
Log::debug('Will not seed exchange rates yet.');
return;
}
$users = User::get();
$date = config('cer.date');
$rates = config('cer.rates');
$usable = [];
foreach ($rates as $rate) {
$from = $this->getCurrency($rate[0]);
$to = $this->getCurrency($rate[1]);
if (null !== $from && null !== $to) {
$usable[] = [$from, $to, $rate[2]];
}
}
unset($rates, $from, $to, $rate);
/** @var User $user */
foreach ($users as $user) {
foreach ($usable as $rate) {
if (!$this->hasRate($user, $rate[0], $rate[1], $date)) {
$this->addRate($user, $rate[0], $rate[1], $date, $rate[2]);
}
}
}
}
/**
* @param string $code
* @return TransactionCurrency|null
*/
private function getCurrency(string $code): ?TransactionCurrency
{
return TransactionCurrency::whereNull('deleted_at')->where('code', $code)->first();
}
/**
* @param User $user
* @param TransactionCurrency $from
* @param TransactionCurrency $to
* @param string $date
* @return bool
*/
private function hasRate(User $user, TransactionCurrency $from, TransactionCurrency $to, string $date): bool
{
return $user->currencyExchangeRates()
->where('from_currency_id', $from->id)
->where('to_currency_id', $to->id)
->where('date', $date)
->count() > 0;
}
/**
* @param User $user
* @param TransactionCurrency $from
* @param TransactionCurrency $to
* @param string $date
* @param float $rate
* @return void
*/
private function addRate(User $user, TransactionCurrency $from, TransactionCurrency $to, string $date, float $rate): void
{
/** @var User $user */
CurrencyExchangeRate::create(
[
'user_id' => $user->id,
'from_currency_id' => $from->id,
'to_currency_id' => $to->id,
'date' => $date,
'rate' => $rate,
]
);
}
}

View File

@ -24,6 +24,7 @@
<script>
import {defineComponent} from 'vue';
import Preferences from "./api/preferences";
import Prefs from "./api/v2/preferences";
import Currencies from "./api/currencies";
import {useFireflyIIIStore} from 'stores/fireflyiii'
@ -74,10 +75,22 @@ export default defineComponent(
});
};
const getLocale = function () {
return (new Prefs).get('locale').then(data => {
const locale = data.data.data.attributes.data.replace('_','-');
ffStore.setLocale(locale);
}).catch((err) => {
console.error('Could not load locale.')
console.log(err);
});
};
getDefaultCurrency().then(() => {
getViewRange();
getListPageSize();
getLocale();
});
}
})

38
frontend/src/api/v2/budgets/sum.js vendored Normal file
View File

@ -0,0 +1,38 @@
/*
* list.js
* Copyright (c) 2022 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {api} from "boot/axios";
import {format} from "date-fns";
export default class Sum {
budgeted(start, end) {
let url = 'api/v2/budgets/sum/budgeted';
let startStr = format(start, 'y-MM-dd');
let endStr = format(end, 'y-MM-dd');
return api.get(url, {params: {start: startStr, end: endStr}});
}
// /*paid(start, end) {
// let url = 'api/v2/bills/sum/paid';
// let startStr = format(start, 'y-MM-dd');
// let endStr = format(end, 'y-MM-dd');
// return api.get(url, {params: {start: startStr, end: endStr}});
// }*/
}

View File

@ -0,0 +1,30 @@
/*
* basic.js
* Copyright (c) 2021 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {api} from "boot/axios";
export default class Preferences {
get(name) {
return api.get('/api/v2/preferences/' + name);
}
// postByName(name, value) {
// return api.post('/api/v1/preferences', {name: name, data: value});
// }
}

View File

@ -26,7 +26,7 @@
<q-card bordered>
<q-item>
<q-item-section>
<q-item-label>{{ $t('firefly.bills_to_pay') }}</q-item-label>
<q-item-label><strong>{{ $t('firefly.bills_to_pay') }}</strong></q-item-label>
</q-item-section>
</q-item>
<q-separator/>
@ -41,36 +41,22 @@
/>
</q-card-section>
<q-separator vertical/>
<q-card-section>
{{ $t('firefly.bills_to_pay') }}:
<q-card-section v-if="0 === unpaid.length && 0 === paid.length">
You have no bills
</q-card-section>
<q-card-section v-if="unpaid.length > 0 || paid.length > 0">
<span :title="formatAmount(this.currency, this.unpaidAmount)">{{ $t('firefly.bills_to_pay') }}</span>:
<span v-for="(bill, index) in unpaid">
{{ formatAmount(bill.code, bill.sum) }}
<span v-if="index+1 !== unpaid.length">, </span>
</span>
<span v-if="bill.native">(</span>{{ formatAmount(bill.code, bill.sum) }}<span
v-if="bill.native">)</span><span v-if="index+1 !== unpaid.length">, </span></span>
<br/>
{{ $t('firefly.bills_paid') }}:
<span v-for="(bill, index) in paid">
{{ formatAmount(bill.code, bill.sum) }}
<span v-if="index+1 !== paid.length">, </span>
</span>
<span :title="formatAmount(this.currency, this.paidAmount)">{{ $t('firefly.bills_paid') }}</span>:
<span v-for="(bill, index) in paid"><span v-if="bill.native">(</span>{{
formatAmount(bill.code, bill.sum)
}}<span v-if="bill.native">)</span><span
v-if="index+1 !== paid.length">, </span></span>
</q-card-section>
</q-card-section>
<!--
<q-card-section class="q-pt-xs">
<div class="text-overline">
<span class="float-right">
<span class="text-grey-4 fas fa-redo-alt" style="cursor: pointer;" @click="triggerForcedUpgrade"></span>
</span>
</div>
</q-card-section>
<q-card-section class="q-pt-xs">
<span v-for="(bill, index) in unpaid">
{{ formatAmount(bill.code, bill.sum) }}
<span v-if="index+1 !== unpaid.length">, </span>
</span>
</q-card-section>
-->
</q-card>
</div>
</template>
@ -85,7 +71,7 @@ export default {
store: null,
unpaid: [],
paid: [],
//percentage: 0,
currency: 'EUR',
unpaidAmount: 0.0,
paidAmount: 0.0,
range: {
@ -100,16 +86,18 @@ export default {
if (0 === this.unpaidAmount) {
return 100;
}
const sum = this.unpaidAmount + this.paidAmount;
if (0.0 === this.paidAmount) {
return 0;
}
return (this.paidAmount / sum) * 100;
const pct = (this.paidAmount / this.unpaidAmount) * 100;
if (pct > 100) {
return 100;
}
return pct;
}
},
mounted() {
this.store = useFireflyIIIStore();
// TODO this code snippet is recycled a lot.
if (null === this.range.start || null === this.range.end) {
// subscribe, then update:
@ -133,25 +121,30 @@ export default {
const start = new Date(this.store.getRange.start);
const end = new Date(this.store.getRange.end);
const sum = new Sum;
this.currency = this.store.getCurrencyCode;
sum.unpaid(start, end).then((response) => this.parseUnpaidResponse(response.data));
sum.paid(start, end).then((response) => this.parsePaidResponse(response.data));
}
},
// TODO this method is recycled a lot.
formatAmount: function (currencyCode, amount) {
// TODO not yet internationalized
return Intl.NumberFormat('en-US', {style: 'currency', currency: currencyCode}).format(amount);
return Intl.NumberFormat(this.store.getLocale, {style: 'currency', currency: currencyCode}).format(amount);
},
parseUnpaidResponse: function (data) {
for (let i in data) {
if (data.hasOwnProperty(i)) {
const current = data[i];
const hasNative = current.native_id !== current.id && current.native_sum !== '0';
this.unpaid.push(
{
sum: current.sum,
code: current.code,
native: hasNative,
}
);
this.unpaidAmount = this.unpaidAmount + parseFloat(current.sum);
if (hasNative || current.native_id === current.id) {
this.unpaidAmount = this.unpaidAmount + parseFloat(current.native_sum);
}
}
}
},
@ -159,20 +152,20 @@ export default {
for (let i in data) {
if (data.hasOwnProperty(i)) {
const current = data[i];
const hasNative = current.native_id !== current.id && parseFloat(current.native_sum) !== 0.0;
this.paid.push(
{
sum: current.sum,
code: current.code,
native: hasNative,
}
);
this.paidAmount = this.paidAmount + (parseFloat(current.sum) * -1);
if (hasNative || current.native_id === current.id) {
this.paidAmount = this.paidAmount + (parseFloat(current.native_sum) * -1);
}
}
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,152 @@
<!--
- BillInsightBox.vue
- Copyright (c) 2022 james@firefly-iii.org
-
- This file is part of Firefly III (https://github.com/firefly-iii).
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<!-- TODO most left? q-mr-sm -->
<!-- TODO middle? dan q-mx-sm -->
<!-- TODO right? dan q-ml-sm -->
<div class="q-mx-sm">
<q-card bordered>
<q-item>
<q-item-section>
<q-item-label><strong>Spend</strong></q-item-label>
</q-item-section>
</q-item>
<q-separator/>
<q-card-section horizontal>
<q-card-section>
<q-circular-progress
:value="percentage"
size="50px"
:thickness="0.22"
color="green"
track-color="grey-3"
/>
</q-card-section>
<q-separator vertical/>
<q-card-section v-if="0 === budgeted.length && 0 === spent.length">
You have no budgets set
</q-card-section>
<q-card-section v-if="budgeted.length > 0 || spent.length > 0">
<span :title="formatAmount(this.currency, this.budgetedAmount)">Budgeted</span>:
<span v-for="(budget, index) in budgeted"><span v-if="budget.native">(</span>{{ formatAmount(budget.code, budget.sum) }}<span v-if="budget.native">)</span><span
v-if="index+1 !== budgeted.length">, </span></span>
</q-card-section>
</q-card-section>
</q-card>
</div>
</template>
<script>
import {useFireflyIIIStore} from "../../stores/fireflyiii";
import Sum from "../../api/v2/budgets/sum";
export default {
data() {
return {
store: null,
budgeted: [],
spent: [],
currency: 'EUR',
//percentage: 0,
budgetedAmount: 0.0,
spentAmount: 0.0,
range: {
start: null,
end: null,
},
}
},
name: "SpendInsightBox",
computed: {
percentage: function () {
if (0 === this.budgetedAmount) {
return 100;
}
if (0.0 === this.spentAmount) {
return 0;
}
const pct = (this.spentAmount / this.budgetedAmount) * 100;
if (pct > 100) {
return 100;
}
return pct;
}
},
mounted() {
this.store = useFireflyIIIStore();
// TODO this code snippet is recycled a lot.
if (null === this.range.start || null === this.range.end) {
// subscribe, then update:
this.store.$onAction(
({name, $store, args, after, onError,}) => {
after((result) => {
if (name === 'setRange') {
this.range = result;
this.triggerUpdate();
}
})
}
)
}
this.triggerUpdate();
},
methods: {
triggerUpdate: function () {
if (null !== this.store.getRange.start && null !== this.store.getRange.end) {
this.budgeted = [];
const start = new Date(this.store.getRange.start);
const end = new Date(this.store.getRange.end);
const sum = new Sum;
this.currency = this.store.getCurrencyCode;
sum.budgeted(start, end).then((response) => this.parseBudgetedResponse(response.data));
//sum.paid(start, end).then((response) => this.parsePaidResponse(response.data));
}
},
// TODO this method is recycled a lot.
formatAmount: function (currencyCode, amount) {
return Intl.NumberFormat(this.store.getLocale, {style: 'currency', currency: currencyCode}).format(amount);
},
parseBudgetedResponse: function (data) {
for (let i in data) {
if (data.hasOwnProperty(i)) {
const current = data[i];
const hasNative = current.native_id !== current.id && parseFloat(current.native_sum) !== 0.0;
this.budgeted.push(
{
sum: current.sum,
code: current.code,
native: hasNative
}
);
if (hasNative || current.native_id === current.id) {
this.budgetedAmount = this.budgetedAmount + parseFloat(current.native_sum);
}
}
}
},
}
}
</script>
<style scoped>
</style>

View File

@ -27,7 +27,7 @@
<BillInsightBox />
</div>
<div class="col">
TODO spend insight
<SpendInsightBox />
</div>
<div class="col">
TODO net worth insight
@ -94,6 +94,7 @@ export default {
name: "Dashboard",
components: {
BillInsightBox: defineAsyncComponent(() => import('../../components/dashboard/BillInsightBox.vue')),
SpendInsightBox: defineAsyncComponent(() => import('../../components/dashboard/SpendInsightBox.vue')),
}
}
</script>

View File

@ -36,97 +36,3 @@ export function resetRange(context) {
context.commit('setRange', defaultRange);
}
export function setDatesFromViewRange(context) {
let start;
let end;
let viewRange = context.getters.getViewRange;
let today = new Date;
switch (viewRange) {
case 'last365':
start = startOfDay(subDays(today, 365));
end = endOfDay(today);
break;
case 'last90':
start = startOfDay(subDays(today, 90));
end = endOfDay(today);
break;
case 'last30':
start = startOfDay(subDays(today, 30));
end = endOfDay(today);
break;
case 'last7':
start = startOfDay(subDays(today, 7));
end = endOfDay(today);
break;
case 'YTD':
start = startOfYear(today);
end = endOfDay(today);
break;
case 'QTD':
start = startOfQuarter(today);
end = endOfDay(today);
break;
case 'MTD':
start = startOfMonth(today);
end = endOfDay(today);
break;
case '1D':
// today:
start = startOfDay(today);
end = endOfDay(today);
break;
case '1W':
// this week:
start = startOfDay(startOfWeek(today, {weekStartsOn: 1}));
end = endOfDay(endOfWeek(today, {weekStartsOn: 1}));
break;
case '1M':
// this month:
start = startOfDay(startOfMonth(today));
end = endOfDay(endOfMonth(today));
break;
case '3M':
// this quarter
start = startOfDay(startOfQuarter(today));
end = endOfDay(endOfQuarter(today));
break;
case '6M':
// this half-year
if (today.getMonth() <= 5) {
start = new Date(today);
start.setMonth(0);
start.setDate(1);
start = startOfDay(start);
end = new Date(today);
end.setMonth(5);
end.setDate(30);
end = endOfDay(start);
}
if (today.getMonth() > 5) {
start = new Date(today);
start.setMonth(6);
start.setDate(1);
start = startOfDay(start);
end = new Date(today);
end.setMonth(11);
end.setDate(31);
end = endOfDay(start);
}
break;
case '1Y':
// this year
start = new Date(today);
start.setMonth(0);
start.setDate(1);
start = startOfDay(start);
end = new Date(today);
end.setMonth(11);
end.setDate(31);
end = endOfDay(end);
break;
}
context.commit('setRange', {start: start, end: end});
context.commit('setDefaultRange', {start: start, end: end});
}

View File

@ -12,19 +12,31 @@ import {
subDays
} from "date-fns";
export const useFireflyIIIStore = defineStore('counter', {
export const useFireflyIIIStore = defineStore('firefly-iii', {
state: () => ({
drawerState: true, viewRange: '1M', listPageSize: 10, range: {
drawerState: true,
viewRange: '1M',
listPageSize: 10,
locale: 'en-US',
range: {
start: null, end: null
}, defaultRange: {
start: null, end: null
}, currencyCode: 'AAA', currencyId: '0', cacheKey: 'initial'
},
defaultRange: {
start: null,
end: null
},
currencyCode: 'AAA',
currencyId: '0',
cacheKey: 'initial'
}),
getters: {
getViewRange(state) {
return state.viewRange;
},
getLocale(state) {
return state.locale;
},
getListPageSize(state) {
return state.listPageSize;
@ -156,9 +168,6 @@ export const useFireflyIIIStore = defineStore('counter', {
// mutators
increment() {
this.counter++
},
updateViewRange(viewRange) {
this.viewRange = viewRange;
},
@ -166,6 +175,9 @@ export const useFireflyIIIStore = defineStore('counter', {
updateListPageSize(value) {
this.listPageSize = value;
},
setLocale(value) {
this.locale = value;
},
setRange(value) {
this.range = value;

View File

@ -67,11 +67,11 @@
<div class="input-group mb-3">
{% if config('firefly.authentication_guard') == 'web' %}
<input type="email" class="form-control" name="email"
<input type="email" id="focus" class="form-control" name="email"
placeholder="{{ trans('form.email') }}"
value="{% if not IS_DEMO_SITE %}{{ email }}{% else %}{{ DEMO_USERNAME }}{% endif %}">
{% else %}
<input type="text" autocomplete="username" name="{{ usernameField }}" value="{{ email }}"
<input type="text" id="focus" autocomplete="username" name="{{ usernameField }}" value="{{ email }}"
class="form-control" placeholder="{{ trans('form.login_name') }}"/>
{% endif %}
<div class="input-group-append">
@ -126,4 +126,11 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script nonce="{{ JS_NONCE }}">
$(function () {
"use strict";
$('#focus').focus();
});
</script>
{% endblock %}

View File

@ -7,19 +7,21 @@
<title>{{ 'login_page_title'|_ }}</title>
<!-- fonts and styles -->
<link rel="stylesheet" href="/v3-local/css/fonts.css">
<link rel="stylesheet" href="/v3-local/lib/fontawesome-free/css/all.min.css">
<link rel="stylesheet" href="/v3-local/lib/icheck-bootstrap/icheck-bootstrap.min.css">
<link rel="stylesheet" href="/v3-local/dist/css/adminlte.min.css">
<link rel="stylesheet" href="/v3-local/css/fonts.css?v={{ FF_VERSION }}">
<link rel="stylesheet" href="/v3-local/lib/fontawesome-free/css/all.min.css?v={{ FF_VERSION }}">
<link rel="stylesheet" href="/v3-local/lib/icheck-bootstrap/icheck-bootstrap.min.css?v={{ FF_VERSION }}">
<link rel="stylesheet" href="/v3-local/dist/css/adminlte.min.css?v={{ FF_VERSION }}">
</head>
<body class="hold-transition login-page dark-mode">
{% block content %}{% endblock %}
<!-- jQuery -->
<script src="v3-local/lib/jquery/jquery.min.js"></script>
<script src="v3-local/lib/jquery/jquery.min.js?v={{ FF_VERSION }}" nonce="{{ JS_NONCE }}"></script>
<!-- Bootstrap 4 -->
<script src="v3-local/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="v3-local/lib/bootstrap/js/bootstrap.bundle.min.js?v={{ FF_VERSION }}" nonce="{{ JS_NONCE }}"></script>
<!-- AdminLTE App -->
<script src="v3-local/dist/js/adminlte.min.js"></script>
<script src="v3-local/dist/js/adminlte.min.js?v={{ FF_VERSION }}" nonce="{{ JS_NONCE }}"></script>
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@ -45,6 +45,29 @@ Route::group(
}
);
/**
* V2 API route for bills.
*/
Route::group(
['namespace' => 'FireflyIII\Api\V2\Controllers\Model\Budget', 'prefix' => 'v2/budgets',
'as' => 'api.v2.budgets',],
static function () {
Route::get('sum/budgeted', ['uses' => 'SumController@budgeted', 'as' => 'sum.budgeted']);
Route::get('sum/unpaid', ['uses' => 'SumController@unpaid', 'as' => 'sum.unpaid']);
}
);
/**
* V2 API route for system
*/
Route::group(
['namespace' => 'FireflyIII\Api\V2\Controllers\System', 'prefix' => 'v2',
'as' => 'api.v2.system.',],
static function () {
Route::get('preferences/{preference}', ['uses' => 'PreferencesController@get', 'as' => 'preferences.get']);
}
);
/**
* Autocomplete controllers
*/