Improve bill overview.

This commit is contained in:
James Cole
2025-07-20 14:02:53 +02:00
parent 3bafcb6ad2
commit 93284682c8
4 changed files with 145 additions and 119 deletions

View File

@@ -312,11 +312,23 @@ class BillRepository implements BillRepositoryInterface, UserGroupInterface
Log::debug(sprintf('Search for linked journals between %s and %s', $start->toW3cString(), $end->toW3cString())); Log::debug(sprintf('Search for linked journals between %s and %s', $start->toW3cString(), $end->toW3cString()));
return $bill->transactionJournals() return $bill->transactionJournals()
->leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id')
->leftJoin('transaction_currencies AS currency', 'currency.id', '=', 'transactions.transaction_currency_id')
->leftJoin('transaction_currencies AS foreign_currency', 'foreign_currency.id', '=', 'transactions.foreign_currency_id')
->where('transactions.amount', '>', 0)
->before($end)->after($start)->get( ->before($end)->after($start)->get(
[ [
'transaction_journals.id', 'transaction_journals.id',
'transaction_journals.date', 'transaction_journals.date',
'transaction_journals.transaction_group_id', 'transaction_journals.transaction_group_id',
'transactions.transaction_currency_id',
'currency.code AS transaction_currency_code',
'currency.decimal_places AS transaction_currency_decimal_places',
'transactions.foreign_currency_id',
'foreign_currency.code AS foreign_currency_code',
'foreign_currency.decimal_places AS foreign_currency_decimal_places',
'transactions.amount',
'transactions.foreign_amount',
] ]
) )
; ;

View File

@@ -32,8 +32,10 @@ use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionJournal;
use FireflyIII\Repositories\Bill\BillRepositoryInterface; use FireflyIII\Repositories\Bill\BillRepositoryInterface;
use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Facades\Steam;
use FireflyIII\Support\Models\BillDateCalculator; use FireflyIII\Support\Models\BillDateCalculator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/** /**
* Class BillTransformer * Class BillTransformer
@@ -64,23 +66,23 @@ class BillTransformer extends AbstractTransformer
*/ */
public function transform(Bill $bill): array public function transform(Bill $bill): array
{ {
$default = $this->parameters->get('defaultCurrency') ?? $this->default; $default = $this->parameters->get('defaultCurrency') ?? $this->default;
$paidData = $this->paidData($bill); $paidData = $this->paidData($bill);
$lastPaidDate = $this->getLastPaidDate($paidData); $lastPaidDate = $this->getLastPaidDate($paidData);
$start = $this->parameters->get('start') ?? today()->subYears(10); $start = $this->parameters->get('start') ?? today()->subYears(10);
$end = $this->parameters->get('end') ?? today()->addYears(10); $end = $this->parameters->get('end') ?? today()->addYears(10);
$payDates = $this->calculator->getPayDates($start, $end, $bill->date, $bill->repeat_freq, $bill->skip, $lastPaidDate); $payDates = $this->calculator->getPayDates($start, $end, $bill->date, $bill->repeat_freq, $bill->skip, $lastPaidDate);
$currency = $bill->transactionCurrency; $currency = $bill->transactionCurrency;
$notes = $this->repository->getNoteText($bill); $notes = $this->repository->getNoteText($bill);
$notes = '' === $notes ? null : $notes; $notes = '' === $notes ? null : $notes;
$objectGroupId = null; $objectGroupId = null;
$objectGroupOrder = null; $objectGroupOrder = null;
$objectGroupTitle = null; $objectGroupTitle = null;
$this->repository->setUser($bill->user); $this->repository->setUser($bill->user);
/** @var null|ObjectGroup $objectGroup */ /** @var null|ObjectGroup $objectGroup */
$objectGroup = $bill->objectGroups->first(); $objectGroup = $bill->objectGroups->first();
if (null !== $objectGroup) { if (null !== $objectGroup) {
$objectGroupId = $objectGroup->id; $objectGroupId = $objectGroup->id;
$objectGroupOrder = $objectGroup->order; $objectGroupOrder = $objectGroup->order;
@@ -90,7 +92,7 @@ class BillTransformer extends AbstractTransformer
$paidDataFormatted = []; $paidDataFormatted = [];
$payDatesFormatted = []; $payDatesFormatted = [];
foreach ($paidData as $object) { foreach ($paidData as $object) {
$date = Carbon::createFromFormat('!Y-m-d', $object['date'], config('app.timezone')); $date = Carbon::createFromFormat('!Y-m-d', $object['date'], config('app.timezone'));
if (!$date instanceof Carbon) { if (!$date instanceof Carbon) {
$date = today(config('app.timezone')); $date = today(config('app.timezone'));
} }
@@ -99,24 +101,24 @@ class BillTransformer extends AbstractTransformer
} }
foreach ($payDates as $string) { foreach ($payDates as $string) {
$date = Carbon::createFromFormat('!Y-m-d', $string, config('app.timezone')); $date = Carbon::createFromFormat('!Y-m-d', $string, config('app.timezone'));
if (!$date instanceof Carbon) { if (!$date instanceof Carbon) {
$date = today(config('app.timezone')); $date = today(config('app.timezone'));
} }
$payDatesFormatted[] = $date->toAtomString(); $payDatesFormatted[] = $date->toAtomString();
} }
// next expected match // next expected match
$nem = null; $nem = null;
$nemDate = null; $nemDate = null;
$nemDiff = trans('firefly.not_expected_period'); $nemDiff = trans('firefly.not_expected_period');
$firstPayDate = $payDates[0] ?? null; $firstPayDate = $payDates[0] ?? null;
if (null !== $firstPayDate) { if (null !== $firstPayDate) {
$nemDate = Carbon::createFromFormat('!Y-m-d', $firstPayDate, config('app.timezone')); $nemDate = Carbon::createFromFormat('!Y-m-d', $firstPayDate, config('app.timezone'));
if (!$nemDate instanceof Carbon) { if (!$nemDate instanceof Carbon) {
$nemDate = today(config('app.timezone')); $nemDate = today(config('app.timezone'));
} }
$nem = $nemDate->toAtomString(); $nem = $nemDate->toAtomString();
// nullify again when it's outside the current view range. // nullify again when it's outside the current view range.
if ( if (
@@ -137,7 +139,7 @@ class BillTransformer extends AbstractTransformer
$current = $payDatesFormatted[0] ?? null; $current = $payDatesFormatted[0] ?? null;
if (null !== $current && !$nemDate->isToday()) { if (null !== $current && !$nemDate->isToday()) {
$temp2 = Carbon::createFromFormat('Y-m-d\TH:i:sP', $current); $temp2 = Carbon::createFromFormat('Y-m-d\TH:i:sP', $current);
if (!$temp2 instanceof Carbon) { if (!$temp2 instanceof Carbon) {
$temp2 = today(config('app.timezone')); $temp2 = today(config('app.timezone'));
} }
@@ -150,11 +152,11 @@ class BillTransformer extends AbstractTransformer
'id' => $bill->id, 'id' => $bill->id,
'created_at' => $bill->created_at->toAtomString(), 'created_at' => $bill->created_at->toAtomString(),
'updated_at' => $bill->updated_at->toAtomString(), 'updated_at' => $bill->updated_at->toAtomString(),
'currency_id' => (string) $bill->transaction_currency_id, 'currency_id' => (string)$bill->transaction_currency_id,
'currency_code' => $currency->code, 'currency_code' => $currency->code,
'currency_symbol' => $currency->symbol, 'currency_symbol' => $currency->symbol,
'currency_decimal_places' => $currency->decimal_places, 'currency_decimal_places' => $currency->decimal_places,
'native_currency_id' => null === $default ? null : (string) $default->id, 'native_currency_id' => null === $default ? null : (string)$default->id,
'native_currency_code' => $default?->code, 'native_currency_code' => $default?->code,
'native_currency_symbol' => $default?->symbol, 'native_currency_symbol' => $default?->symbol,
'native_currency_decimal_places' => $default?->decimal_places, 'native_currency_decimal_places' => $default?->decimal_places,
@@ -171,7 +173,7 @@ class BillTransformer extends AbstractTransformer
'active' => $bill->active, 'active' => $bill->active,
'order' => $bill->order, 'order' => $bill->order,
'notes' => $notes, 'notes' => $notes,
'object_group_id' => null !== $objectGroupId ? (string) $objectGroupId : null, 'object_group_id' => null !== $objectGroupId ? (string)$objectGroupId : null,
'object_group_order' => $objectGroupOrder, 'object_group_order' => $objectGroupOrder,
'object_group_title' => $objectGroupTitle, 'object_group_title' => $objectGroupTitle,
@@ -183,7 +185,7 @@ class BillTransformer extends AbstractTransformer
'links' => [ 'links' => [
[ [
'rel' => 'self', 'rel' => 'self',
'uri' => '/bills/'.$bill->id, 'uri' => '/bills/' . $bill->id,
], ],
], ],
]; ];
@@ -194,9 +196,9 @@ class BillTransformer extends AbstractTransformer
*/ */
protected function paidData(Bill $bill): array protected function paidData(Bill $bill): array
{ {
app('log')->debug(sprintf('Now in paidData for bill #%d', $bill->id)); Log::debug(sprintf('Now in paidData for bill #%d', $bill->id));
if (null === $this->parameters->get('start') || null === $this->parameters->get('end')) { if (null === $this->parameters->get('start') || null === $this->parameters->get('end')) {
app('log')->debug('parameters are NULL, return empty array'); Log::debug('parameters are NULL, return empty array');
return []; return [];
} }
@@ -205,39 +207,52 @@ class BillTransformer extends AbstractTransformer
// 2023-07-18 this particular date is used to search for the last paid date. // 2023-07-18 this particular date is used to search for the last paid date.
// 2023-07-18 the cloned $searchDate is used to grab the correct transactions. // 2023-07-18 the cloned $searchDate is used to grab the correct transactions.
/** @var Carbon $start */ /** @var Carbon $start */
$start = clone $this->parameters->get('start'); $start = clone $this->parameters->get('start');
$searchStart = clone $start; $searchStart = clone $start;
$start->subDay(); $start->subDay();
/** @var Carbon $end */ /** @var Carbon $end */
$end = clone $this->parameters->get('end'); $end = clone $this->parameters->get('end');
$searchEnd = clone $end; $searchEnd = clone $end;
// move the search dates to the start of the day. // move the search dates to the start of the day.
$searchStart->startOfDay(); $searchStart->startOfDay();
$searchEnd->endOfDay(); $searchEnd->endOfDay();
app('log')->debug(sprintf('Parameters are start: %s end: %s', $start->format('Y-m-d'), $end->format('Y-m-d'))); Log::debug(sprintf('Parameters are start: %s end: %s', $start->format('Y-m-d'), $end->format('Y-m-d')));
app('log')->debug(sprintf('Search parameters are: start: %s', $searchStart->format('Y-m-d'))); Log::debug(sprintf('Search parameters are: start: %s', $searchStart->format('Y-m-d')));
// Get from database when bill was paid. // Get from database when bill was paid.
$set = $this->repository->getPaidDatesInRange($bill, $searchStart, $searchEnd); $set = $this->repository->getPaidDatesInRange($bill, $searchStart, $searchEnd);
app('log')->debug(sprintf('Count %d entries in getPaidDatesInRange()', $set->count())); Log::debug(sprintf('Count %d entries in getPaidDatesInRange()', $set->count()));
// Grab from array the most recent payment. If none exist, fall back to the start date and pretend *that* was the last paid date. // Grab from array the most recent payment. If none exist, fall back to the start date and pretend *that* was the last paid date.
app('log')->debug(sprintf('Grab last paid date from function, return %s if it comes up with nothing.', $start->format('Y-m-d'))); Log::debug(sprintf('Grab last paid date from function, return %s if it comes up with nothing.', $start->format('Y-m-d')));
$lastPaidDate = $this->lastPaidDate($set, $start); $lastPaidDate = $this->lastPaidDate($set, $start);
app('log')->debug(sprintf('Result of lastPaidDate is %s', $lastPaidDate->format('Y-m-d'))); Log::debug(sprintf('Result of lastPaidDate is %s', $lastPaidDate->format('Y-m-d')));
// At this point the "next match" is exactly after the last time the bill was paid. // At this point the "next match" is exactly after the last time the bill was paid.
$result = []; $result = [];
foreach ($set as $entry) { foreach ($set as $entry) {
$result[] = [ $array = [
'transaction_group_id' => (string) $entry->transaction_group_id, 'transaction_group_id' => (string)$entry->transaction_group_id,
'transaction_journal_id' => (string) $entry->id, 'transaction_journal_id' => (string)$entry->id,
'date' => $entry->date->format('Y-m-d'), 'date' => $entry->date->format('Y-m-d'),
'date_object' => $entry->date, 'date_object' => $entry->date,
'currency_id' => $entry->transaction_currency_id,
'currency_code' => $entry->transaction_currency_code,
'currency_decimal_places' => $entry->transaction_currency_decimal_places,
'amount' => Steam::bcround($entry->amount,$entry->transaction_currency_decimal_places),
]; ];
if (null !== $entry->foreign_amount && null !== $entry->foreign_currency_code) {
$array['foreign_currency_id'] = $entry->foreign_currency_id;
$array['foreign_currency_code'] = $entry->foreign_currency_code;
$array['foreign_currency_decimal_places'] = $entry->foreign_currency_decimal_places;
$array['foreign_amount'] = Steam::bcround($entry->foreign_amount,$entry->foreign_currency_decimal_places);
}
$result[] = $array;
} }
return $result; return $result;
@@ -265,7 +280,7 @@ class BillTransformer extends AbstractTransformer
private function getLastPaidDate(array $paidData): ?Carbon private function getLastPaidDate(array $paidData): ?Carbon
{ {
app('log')->debug('getLastPaidDate()'); Log::debug('getLastPaidDate()');
$return = null; $return = null;
foreach ($paidData as $entry) { foreach ($paidData as $entry) {
if (null !== $return) { if (null !== $return) {
@@ -274,15 +289,15 @@ class BillTransformer extends AbstractTransformer
if ($current->gt($return)) { if ($current->gt($return)) {
$return = clone $current; $return = clone $current;
} }
app('log')->debug(sprintf('Last paid date is: %s', $return->format('Y-m-d'))); Log::debug(sprintf('Last paid date is: %s', $return->format('Y-m-d')));
} }
if (null === $return) { if (null === $return) {
/** @var Carbon $return */ /** @var Carbon $return */
$return = $entry['date_object']; $return = $entry['date_object'];
app('log')->debug(sprintf('Last paid date is: %s', $return->format('Y-m-d'))); Log::debug(sprintf('Last paid date is: %s', $return->format('Y-m-d')));
} }
} }
app('log')->debug(sprintf('Last paid date is: "%s"', $return?->format('Y-m-d'))); Log::debug(sprintf('Last paid date is: "%s"', $return?->format('Y-m-d')));
return $return; return $return;
} }

View File

@@ -31,6 +31,71 @@ let afterPromises = false;
let apiData = []; let apiData = [];
let subscriptionData = {}; let subscriptionData = {};
function addObjectGroupInfo(data) {
let objectGroupId = parseInt(data.object_group_id);
if (!subscriptionData.hasOwnProperty(objectGroupId)) {
subscriptionData[objectGroupId] = {
id: objectGroupId,
title: null === data.object_group_title ? i18next.t('firefly.default_group_title_name_plain') : data.object_group_title,
order: parseInt(data.object_group_order),
payment_info: {},
bills: [],
};
}
}
function parseBillInfo(data) {
let result = {
id: data.id,
name: data.attributes.name,
amount_min: data.attributes.amount_min,
amount_max: data.attributes.amount_max,
amount: (parseFloat(data.attributes.amount_max) + parseFloat(data.attributes.amount_min)) / 2,
currency_code: data.attributes.currency_code,
// paid transactions:
transactions: [],
// unpaid moments
pay_dates: data.attributes.pay_dates,
paid: data.attributes.paid_dates.length > 0,
};
// set variables
result.expected_amount = formatMoney(result.amount, result.currency_code);
result.expected_times = i18next.t('firefly.subscr_expected_x_times', {
times: data.attributes.pay_dates.length,
amount: result.expected_amount
});
return result;
}
function parsePaidTransactions(paid_dates, bill) {
if( !paid_dates || paid_dates.length < 1) {
return [];
}
let result = [];
// add transactions (simpler version)
for (let i in paid_dates) {
if (paid_dates.hasOwnProperty(i)) {
const currentPayment = paid_dates[i];
console.log(currentPayment);
// math: -100+(paid/expected)*100
let percentage = Math.round(-100 + ((parseFloat(currentPayment.amount) ) / parseFloat(bill.amount)) * 100);
let currentTransaction = {
amount: formatMoney(currentPayment.amount, currentPayment.currency_code),
percentage: percentage,
date: format(new Date(currentPayment.date), 'PP'),
foreign_amount: null,
};
if (null !== currentPayment.foreign_currency_code) {
currentTransaction.foreign_amount = currentPayment.foreign_amount;
currentTransaction.foreign_currency_code = currentPayment.foreign_currency_code;
}
result.push(currentTransaction);
}
}
return result;
}
function downloadSubscriptions(params) { function downloadSubscriptions(params) {
const getter = new Get(); const getter = new Get();
return getter.list(params) return getter.list(params)
@@ -41,80 +106,14 @@ function downloadSubscriptions(params) {
for (let i in data) { for (let i in data) {
if (data.hasOwnProperty(i)) { if (data.hasOwnProperty(i)) {
let current = data[i]; let current = data[i];
//console.log(current);
if (current.attributes.active && current.attributes.pay_dates.length > 0) { if (current.attributes.active && current.attributes.pay_dates.length > 0) {
let objectGroupId = null === current.attributes.object_group_id ? 0 : current.attributes.object_group_id; // create or update object group
let objectGroupTitle = null === current.attributes.object_group_title ? i18next.t('firefly.default_group_title_name_plain') : current.attributes.object_group_title; let objectGroupId = parseInt(current.attributes.object_group_id);
let objectGroupOrder = null === current.attributes.object_group_order ? 0 : current.attributes.object_group_order; addObjectGroupInfo(current.attributes);
if (!subscriptionData.hasOwnProperty(objectGroupId)) {
subscriptionData[objectGroupId] = {
id: objectGroupId,
title: objectGroupTitle,
order: objectGroupOrder,
payment_info: {},
bills: [],
};
}
// TODO this conversion needs to be inside some kind of a parsing class.
let bill = {
id: current.id,
name: current.attributes.name,
// amount
amount_min: current.attributes.amount_min,
amount_max: current.attributes.amount_max,
amount: (parseFloat(current.attributes.amount_max) + parseFloat(current.attributes.amount_min)) / 2,
currency_code: current.attributes.currency_code,
// native amount // create and update the bill.
// native_amount_min: current.attributes.native_amount_min, let bill = parseBillInfo(current);
// native_amount_max: current.attributes.native_amount_max, bill.transactions = parsePaidTransactions(current.attributes.paid_dates, bill);
// native_amount: (parseFloat(current.attributes.native_amount_max) + parseFloat(current.attributes.native_amount_min)) / 2,
// native_currency_code: current.attributes.native_currency_code,
// paid transactions:
transactions: [],
// unpaid moments
pay_dates: current.attributes.pay_dates,
paid: current.attributes.paid_dates.length > 0,
};
// set variables
bill.expected_amount = formatMoney(bill.amount, bill.currency_code);
bill.expected_times = i18next.t('firefly.subscr_expected_x_times', {
times: current.attributes.pay_dates.length,
amount: bill.expected_amount
});
// add transactions (simpler version)
for (let iii in current.attributes.paid_dates) {
if (current.attributes.paid_dates.hasOwnProperty(iii)) {
const currentPayment = current.attributes.paid_dates[iii];
let percentage = 100;
// math: -100+(paid/expected)*100
if (params.convertToNative) {
percentage = Math.round(-100 + ((parseFloat(currentPayment.native_amount) * -1) / parseFloat(bill.native_amount)) * 100);
}
if (!params.convertToNative) {
percentage = Math.round(-100 + ((parseFloat(currentPayment.amount) * -1) / parseFloat(bill.amount)) * 100);
}
// TODO fix me
currentPayment.currency_code = 'EUR';
console.log('Currency code: "'+currentPayment+'"');
console.log(currentPayment);
let currentTransaction = {
amount: formatMoney(currentPayment.amount, currentPayment.currency_code),
percentage: percentage,
date: format(new Date(currentPayment.date), 'PP'),
foreign_amount: null,
};
if (null !== currentPayment.foreign_currency_code) {
currentTransaction.foreign_amount = currentPayment.foreign_amount;
currentTransaction.foreign_currency_code = currentPayment.foreign_currency_code;
}
bill.transactions.push(currentTransaction);
}
}
subscriptionData[objectGroupId].bills.push(bill); subscriptionData[objectGroupId].bills.push(bill);
if (0 === current.attributes.paid_dates.length) { if (0 === current.attributes.paid_dates.length) {

View File

@@ -1866,8 +1866,8 @@ return [
'skip_help_text' => 'Use the skip field to create bi-monthly (skip = 1) or other custom intervals.', 'skip_help_text' => 'Use the skip field to create bi-monthly (skip = 1) or other custom intervals.',
'subscription' => 'Subscription', 'subscription' => 'Subscription',
'not_expected_period' => 'Not expected this period', 'not_expected_period' => 'Not expected this period',
'subscriptions_in_group' => 'Subscriptions in group "%{title}"', 'subscriptions_in_group' => 'Subscriptions in group "{{title}}"',
'subscr_expected_x_times' => 'Expect to pay %{amount} %{times} times this period', 'subscr_expected_x_times' => 'Expect to pay {{amount}} {{times}} times this period',
'not_or_not_yet' => 'Not (yet)', 'not_or_not_yet' => 'Not (yet)',
'visit_bill' => 'Visit subscription ":name" at Firefly III', 'visit_bill' => 'Visit subscription ":name" at Firefly III',
'match_between_amounts' => 'Subscription matches transactions between :low and :high.', 'match_between_amounts' => 'Subscription matches transactions between :low and :high.',