diff --git a/app/Api/V2/Controllers/Model/Bill/ShowController.php b/app/Api/V2/Controllers/Model/Bill/ShowController.php new file mode 100644 index 0000000000..b9ad71529c --- /dev/null +++ b/app/Api/V2/Controllers/Model/Bill/ShowController.php @@ -0,0 +1,72 @@ +. + */ + +namespace FireflyIII\Api\V2\Controllers\Model\Bill; + +use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Repositories\Administration\Bill\BillRepositoryInterface; +use FireflyIII\Transformers\V2\BillTransformer; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; + +/** + * Class ShowController + */ +class ShowController extends Controller +{ + private BillRepositoryInterface $repository; + + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + $this->repository = app(BillRepositoryInterface::class); + $this->repository->setAdministrationId(auth()->user()->user_group_id); + return $next($request); + } + ); + } + + /** + * @param Request $request + * + * TODO see autocomplete/accountcontroller for list. + * + * @return JsonResponse + */ + public function index(Request $request): JsonResponse + { + $this->repository->correctOrder(); + $bills = $this->repository->getBills(); + $pageSize = (int)app('preferences')->getForUser(auth()->user(), 'listPageSize', 50)->data; + $count = $bills->count(); + $bills = $bills->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); + $paginator = new LengthAwarePaginator($bills, $count, $pageSize, $this->parameters->get('page')); + $transformer = new BillTransformer(); + $transformer->setParameters($this->parameters); // give params to transformer + + return response() + ->json($this->jsonApiList('subscriptions', $paginator, $transformer)) + ->header('Content-Type', self::CONTENT_TYPE); + } +} diff --git a/app/Transformers/V2/BillTransformer.php b/app/Transformers/V2/BillTransformer.php new file mode 100644 index 0000000000..19bd4cf8ef --- /dev/null +++ b/app/Transformers/V2/BillTransformer.php @@ -0,0 +1,320 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Transformers\V2; + +use Carbon\Carbon; +use Carbon\CarbonInterface; +use FireflyIII\Models\Bill; +use FireflyIII\Models\Note; +use FireflyIII\Models\ObjectGroup; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Models\TransactionJournal; +use FireflyIII\Repositories\Bill\BillRepositoryInterface; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Log; + +/** + * Class BillTransformer + */ +class BillTransformer extends AbstractTransformer +{ + private array $currencies; + private TransactionCurrency $default; + private array $groups; + private array $notes; + private array $paidDates; + private BillRepositoryInterface $repository; + + /** + * BillTransformer constructor. + * + + */ + public function __construct() + { + $this->repository = app(BillRepositoryInterface::class); + } + + /** + * @inheritDoc + */ + public function collectMetaData(Collection $objects): void + { + $currencies = []; + $bills = []; + $this->notes = []; + $this->groups = []; + $this->paidDates = []; + + + // start with currencies: + /** @var Bill $object */ + foreach ($objects as $object) { + $id = (int)$object->transaction_currency_id; + $bills[] = (int)$object->id; + $currencies[$id] = $currencies[$id] ?? TransactionCurrency::find($id); + } + $this->currencies = $currencies; + + // continue with notes + $notes = Note::whereNoteableType(Bill::class)->whereIn('noteable_id', array_keys($bills))->get(); + /** @var Note $note */ + foreach ($notes as $note) { + $id = (int)$note->noteable_id; + $this->notes[$id] = $note; + } + // grab object groups: + $set = DB::table('object_groupables') + ->leftJoin('object_groups', 'object_groups.id', '=', 'object_groupables.object_group_id') + ->where('object_groupables.object_groupable_type', Bill::class) + ->get(['object_groupables.*', 'object_groups.title', 'object_groups.order']); + /** @var ObjectGroup $entry */ + foreach ($set as $entry) { + $billId = (int)$entry->object_groupable_id; + $id = (int)$entry->object_group_id; + $order = (int)$entry->order; + $this->groups[$billId] = [ + 'object_group_id' => $id, + 'object_group_title' => $entry->title, + 'object_group_order' => $order, + ]; + + } + $this->default = app('amount')->getDefaultCurrency(); + + // grab all paid dates: + if (null !== $this->parameters->get('start') && null !== $this->parameters->get('end')) { + $journals = TransactionJournal::whereIn('bill_id', $bills) + ->where('date', '>=', $this->parameters->get('start')) + ->where('date', '<=', $this->parameters->get('end')) + ->get(['transaction_journals.id', 'transaction_journals.transaction_group_id', 'transaction_journals.date', 'transaction_journals.bill_id']); + /** @var TransactionJournal $journal */ + foreach ($journals as $journal) { + $billId = (int)$journal->bill_id; + $this->paidDates[$billId][] = [ + 'transaction_group_id' => (string)$journal->id, + 'transaction_journal_id' => (string)$journal->transaction_group_id, + 'date' => $journal->date->toAtomString(), + ]; + } + } + } + + /** + * Transform the bill. + * + * @param Bill $bill + * + * @return array + */ + public function transform(Bill $bill): array + { + $paidData = $this->paidDates[(int)$bill->id] ?? []; + $nextExpectedMatch = $this->nextExpectedMatch($bill, $this->paidDates[(int)$bill->id] ?? []); + $payDates = $this->payDates($bill); + $currency = $this->currencies[(int)$bill->transaction_currency_id]; + $group = $this->groups[(int)$bill->id] ?? null; + $nextExpectedMatchDiff = $this->getNextExpectedMatchDiff($nextExpectedMatch, $payDates); + return [ + 'id' => (int)$bill->id, + 'created_at' => $bill->created_at->toAtomString(), + 'updated_at' => $bill->updated_at->toAtomString(), + 'name' => $bill->name, + 'amount_min' => app('steam')->bcround($bill->amount_min, $currency->decimal_places), + 'amount_max' => app('steam')->bcround($bill->amount_max, $currency->decimal_places), + 'currency_id' => (string)$bill->transaction_currency_id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => (int)$currency->decimal_places, + 'date' => $bill->date->toAtomString(), + 'end_date' => $bill->end_date?->toAtomString(), + 'extension_date' => $bill->extension_date?->toAtomString(), + 'repeat_freq' => $bill->repeat_freq, + 'skip' => (int)$bill->skip, + 'active' => $bill->active, + 'order' => (int)$bill->order, + 'notes' => $this->notes[(int)$bill->id] ?? null, + 'object_group_id' => $group ? $group['object_group_id'] : null, + 'object_group_order' => $group ? $group['object_group_order'] : null, + 'object_group_title' => $group ? $group['object_group_title'] : null, + 'next_expected_match' => $nextExpectedMatch->toAtomString(), + 'next_expected_match_diff' => $nextExpectedMatchDiff, + 'pay_dates' => $payDates, + 'paid_dates' => $paidData, + 'links' => [ + [ + 'rel' => 'self', + 'uri' => sprintf('/bills/%d', $bill->id), + ], + ], + ]; + } + + /** + * Get the data the bill was paid and predict the next expected match. + * + * @param Bill $bill + * @param array $dates + * + * @return Carbon + */ + protected function nextExpectedMatch(Bill $bill, array $dates): Carbon + { + // 2023-07-1 sub one day from the start date to fix a possible bug (see #7704) + // 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. + /** @var Carbon $start */ + $start = clone $this->parameters->get('start'); + $start->subDay(); + + $lastPaidDate = $this->lastPaidDate($dates, $start); + $nextMatch = clone $bill->date; + while ($nextMatch < $lastPaidDate) { + /* + * As long as this date is smaller than the last time the bill was paid, keep jumping ahead. + * For example: 1 jan, 1 feb, etc. + */ + $nextMatch = app('navigation')->addPeriod($nextMatch, $bill->repeat_freq, $bill->skip); + } + if ($nextMatch->isSameDay($lastPaidDate)) { + /* + * Add another period because it's the same day as the last paid date. + */ + $nextMatch = app('navigation')->addPeriod($nextMatch, $bill->repeat_freq, $bill->skip); + } + return $nextMatch; + } + + /** + * Returns the latest date in the set, or start when set is empty. + * + * @param Collection $dates + * @param Carbon $default + * + * @return Carbon + */ + protected function lastPaidDate(array $dates, Carbon $default): Carbon + { + if (0 === count($dates)) { + return $default; + } + $latest = $dates[0]['date']; + /** @var array $row */ + foreach ($dates as $row) { + $carbon = new Carbon($row['date']); + if ($carbon->gte($latest)) { + $latest = $row['date']; + } + } + + return new Carbon($latest); + } + + /** + * @param Bill $bill + * + * @return array + */ + protected function payDates(Bill $bill): array + { + //Log::debug(sprintf('Now in payDates() for bill #%d', $bill->id)); + if (null === $this->parameters->get('start') || null === $this->parameters->get('end')) { + //Log::debug('No start or end date, give empty array.'); + + return []; + } + $set = new Collection(); + $currentStart = clone $this->parameters->get('start'); + // 2023-06-23 subDay to fix 7655 + $currentStart->subDay(); + $loop = 0; + while ($currentStart <= $this->parameters->get('end')) { + $nextExpectedMatch = $this->nextDateMatch($bill, $currentStart); + // If nextExpectedMatch is after end, we continue: + if ($nextExpectedMatch > $this->parameters->get('end')) { + break; + } + // add to set + $set->push(clone $nextExpectedMatch); + $nextExpectedMatch->addDay(); + $currentStart = clone $nextExpectedMatch; + $loop++; + if ($loop > 4) { + break; + } + } + $simple = $set->map( + static function (Carbon $date) { + return $date->toAtomString(); + } + ); + + return $simple->toArray(); + } + + /** + * Given a bill and a date, this method will tell you at which moment this bill expects its next + * transaction. Whether or not it is there already, is not relevant. + * + * @param Bill $bill + * @param Carbon $date + * + * @return Carbon + */ + protected function nextDateMatch(Bill $bill, Carbon $date): Carbon + { + //Log::debug(sprintf('Now in nextDateMatch(%d, %s)', $bill->id, $date->format('Y-m-d'))); + $start = clone $bill->date; + //Log::debug(sprintf('Bill start date is %s', $start->format('Y-m-d'))); + while ($start < $date) { + $start = app('navigation')->addPeriod($start, $bill->repeat_freq, $bill->skip); + } + + //Log::debug(sprintf('End of loop, bill start date is now %s', $start->format('Y-m-d'))); + + return $start; + } + + /** + * @param Carbon $next + * @param array $dates + * + * @return string + */ + private function getNextExpectedMatchDiff(Carbon $next, array $dates): string + { + if ($next->isToday()) { + return trans('firefly.today'); + } + $current = $dates[0] ?? null; + if (null === $current) { + return trans('firefly.not_expected_period'); + } + $carbon = new Carbon($current); + return $carbon->diffForHumans(today(config('app.timezone')), CarbonInterface::DIFF_RELATIVE_TO_NOW); + } + + +}