diff --git a/app/Api/V2/Controllers/Model/Bill/SumController.php b/app/Api/V2/Controllers/Model/Bill/SumController.php
new file mode 100644
index 0000000000..34378f6064
--- /dev/null
+++ b/app/Api/V2/Controllers/Model/Bill/SumController.php
@@ -0,0 +1,78 @@
+.
+ */
+
+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);
+ }
+}
diff --git a/app/Api/V2/Controllers/Model/Budget/SumController.php b/app/Api/V2/Controllers/Model/Budget/SumController.php
new file mode 100644
index 0000000000..c4b524b87d
--- /dev/null
+++ b/app/Api/V2/Controllers/Model/Budget/SumController.php
@@ -0,0 +1,65 @@
+.
+ */
+
+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);
+ }
+
+}
diff --git a/app/Handlers/Events/UserEventHandler.php b/app/Handlers/Events/UserEventHandler.php
index 2d8c2ca22d..f4f206b9a8 100644
--- a/app/Handlers/Events/UserEventHandler.php
+++ b/app/Handlers/Events/UserEventHandler.php
@@ -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.
*
diff --git a/app/Models/CurrencyExchangeRate.php b/app/Models/CurrencyExchangeRate.php
index 6929f4c470..6a8270b19e 100644
--- a/app/Models/CurrencyExchangeRate.php
+++ b/app/Models/CurrencyExchangeRate.php
@@ -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
diff --git a/app/Models/Preference.php b/app/Models/Preference.php
index d743f360ad..1e71699c52 100644
--- a/app/Models/Preference.php
+++ b/app/Models/Preference.php
@@ -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;
}
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
index 0e867b4732..d046e841c6 100644
--- a/app/Providers/EventServiceProvider.php
+++ b/app/Providers/EventServiceProvider.php
@@ -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 => [
diff --git a/app/Repositories/Budget/BudgetLimitRepository.php b/app/Repositories/Budget/BudgetLimitRepository.php
index 423a7a5451..81490957dd 100644
--- a/app/Repositories/Budget/BudgetLimitRepository.php
+++ b/app/Repositories/Budget/BudgetLimitRepository.php
@@ -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
{
diff --git a/app/Repositories/Budget/BudgetRepository.php b/app/Repositories/Budget/BudgetRepository.php
index 705d1ab03c..855c3aa60a 100644
--- a/app/Repositories/Budget/BudgetRepository.php
+++ b/app/Repositories/Budget/BudgetRepository.php
@@ -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;
+ }
}
diff --git a/app/Repositories/Budget/BudgetRepositoryInterface.php b/app/Repositories/Budget/BudgetRepositoryInterface.php
index d06d560530..912351d704 100644
--- a/app/Repositories/Budget/BudgetRepositoryInterface.php
+++ b/app/Repositories/Budget/BudgetRepositoryInterface.php
@@ -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
*/
diff --git a/app/Support/Http/Api/ConvertsExchangeRates.php b/app/Support/Http/Api/ConvertsExchangeRates.php
new file mode 100644
index 0000000000..4b94e50cbe
--- /dev/null
+++ b/app/Support/Http/Api/ConvertsExchangeRates.php
@@ -0,0 +1,202 @@
+.
+ */
+
+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';
+ }
+
+}
diff --git a/config/cer.php b/config/cer.php
new file mode 100644
index 0000000000..8e173a66f8
--- /dev/null
+++ b/config/cer.php
@@ -0,0 +1,68 @@
+.
+ */
+
+
+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],
+ ],
+];
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 839742aa3c..57b47b7a59 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -41,5 +41,6 @@ class DatabaseSeeder extends Seeder
$this->call(LinkTypeSeeder::class);
$this->call(ConfigSeeder::class);
$this->call(UserRoleSeeder::class);
+ $this->call(ExchangeRateSeeder::class);
}
}
diff --git a/database/seeders/ExchangeRateSeeder.php b/database/seeders/ExchangeRateSeeder.php
new file mode 100644
index 0000000000..08f3fbdada
--- /dev/null
+++ b/database/seeders/ExchangeRateSeeder.php
@@ -0,0 +1,117 @@
+.
+ */
+
+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,
+ ]
+ );
+ }
+}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 4cccea955c..52e5ea1189 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -24,6 +24,7 @@
-
-
diff --git a/frontend/src/components/dashboard/SpendInsightBox.vue b/frontend/src/components/dashboard/SpendInsightBox.vue
new file mode 100644
index 0000000000..c2d0340435
--- /dev/null
+++ b/frontend/src/components/dashboard/SpendInsightBox.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+