mirror of
				https://github.com/firefly-iii/firefly-iii.git
				synced 2025-10-31 02:36:28 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			476 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			476 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| 
 | |
| /*
 | |
|  * SubscriptionEnrichment.php
 | |
|  * Copyright (c) 2025 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/>.
 | |
|  */
 | |
| 
 | |
| declare(strict_types=1);
 | |
| 
 | |
| namespace FireflyIII\Support\JsonApi\Enrichments;
 | |
| 
 | |
| use Carbon\Carbon;
 | |
| use Carbon\CarbonInterface;
 | |
| use Carbon\Exceptions\InvalidFormatException;
 | |
| use FireflyIII\Models\Bill;
 | |
| use FireflyIII\Models\Note;
 | |
| use FireflyIII\Models\ObjectGroup;
 | |
| use FireflyIII\Models\TransactionCurrency;
 | |
| use FireflyIII\Models\TransactionJournal;
 | |
| use FireflyIII\Models\UserGroup;
 | |
| use FireflyIII\Support\Facades\Amount;
 | |
| use FireflyIII\Support\Facades\Steam;
 | |
| use FireflyIII\Support\Http\Api\ExchangeRateConverter;
 | |
| use FireflyIII\Support\Models\BillDateCalculator;
 | |
| use FireflyIII\User;
 | |
| use Illuminate\Database\Eloquent\Model;
 | |
| use Illuminate\Support\Collection;
 | |
| use Illuminate\Support\Facades\DB;
 | |
| use Illuminate\Support\Facades\Log;
 | |
| 
 | |
| class SubscriptionEnrichment implements EnrichmentInterface
 | |
| {
 | |
|     private User                $user;
 | |
|     private UserGroup           $userGroup; // @phpstan-ignore-line
 | |
|     private Collection          $collection;
 | |
|     private readonly bool                $convertToPrimary;
 | |
|     private ?Carbon             $start           = null;
 | |
|     private ?Carbon             $end             = null;
 | |
|     private array               $subscriptionIds = [];
 | |
|     private array               $objectGroups    = [];
 | |
|     private array               $mappedObjects   = [];
 | |
|     private array               $paidDates       = [];
 | |
|     private array               $notes           = [];
 | |
|     private array               $payDates        = [];
 | |
|     private readonly TransactionCurrency $primaryCurrency;
 | |
|     private BillDateCalculator  $calculator;
 | |
| 
 | |
|     public function __construct()
 | |
|     {
 | |
|         $this->convertToPrimary = Amount::convertToPrimary();
 | |
|         $this->primaryCurrency  = Amount::getPrimaryCurrency();
 | |
|     }
 | |
| 
 | |
|     public function enrich(Collection $collection): Collection
 | |
|     {
 | |
|         Log::debug(sprintf('%s(%s item(s))', __METHOD__, $collection->count()));
 | |
|         $this->calculator = app(BillDateCalculator::class);
 | |
|         $this->collection = $collection;
 | |
|         $this->collectSubscriptionIds();
 | |
|         $this->collectNotes();
 | |
|         $this->collectObjectGroups();
 | |
|         $this->collectPaidDates();
 | |
|         $this->collectPayDates();
 | |
| 
 | |
| 
 | |
|         // TODO clean me up.
 | |
| 
 | |
|         $notes            = $this->notes;
 | |
|         $objectGroups     = $this->objectGroups;
 | |
|         $paidDates        = $this->paidDates;
 | |
|         $payDates         = $this->payDates;
 | |
|         $this->collection = $this->collection->map(function (Bill $item) use ($notes, $objectGroups, $paidDates, $payDates) {
 | |
|             $id            = (int)$item->id;
 | |
|             $currency      = $item->transactionCurrency;
 | |
|             $nem           = $this->getNextExpectedMatch($payDates[$id] ?? []);
 | |
| 
 | |
|             $meta          = [
 | |
|                 'notes'              => null,
 | |
|                 'object_group_id'    => null,
 | |
|                 'object_group_title' => null,
 | |
|                 'object_group_order' => null,
 | |
|                 'last_paid_date'     => $this->getLastPaidDate($paidDates[$id] ?? []),
 | |
|                 'paid_dates'         => $this->filterPaidDates($paidDates[$id] ?? []),
 | |
|                 'pay_dates'          => $payDates[$id] ?? [],
 | |
|                 'nem'                => $nem,
 | |
|                 'nem_diff'           => $this->getNextExpectedMatchDiff($nem, $payDates[$id] ?? []),
 | |
|             ];
 | |
|             $amounts       = [
 | |
|                 'amount_min'    => Steam::bcround($item->amount_min, $currency->decimal_places),
 | |
|                 'amount_max'    => Steam::bcround($item->amount_max, $currency->decimal_places),
 | |
|                 'average'       => Steam::bcround(bcdiv(bcadd($item->amount_min, $item->amount_max), '2'), $currency->decimal_places),
 | |
|                 'pc_amount_min' => null,
 | |
|                 'pc_amount_max' => null,
 | |
|                 'pc_average'    => null,
 | |
|             ];
 | |
|             if ($this->convertToPrimary && $currency->id === $this->primaryCurrency->id) {
 | |
|                 $amounts['pc_amount_min'] = $amounts['amount_min'];
 | |
|                 $amounts['pc_amount_max'] = $amounts['amount_max'];
 | |
|                 $amounts['pc_average']    = $amounts['average'];
 | |
|             }
 | |
|             if ($this->convertToPrimary && $currency->id !== $this->primaryCurrency->id) {
 | |
|                 $amounts['pc_amount_min'] = Steam::bcround($item->native_amount_min, $this->primaryCurrency->decimal_places);
 | |
|                 $amounts['pc_amount_max'] = Steam::bcround($item->native_amount_max, $this->primaryCurrency->decimal_places);
 | |
|                 $amounts['pc_average']    = Steam::bcround(bcdiv(bcadd($item->native_amount_min, $item->native_amount_max), '2'), $this->primaryCurrency->decimal_places);
 | |
|             }
 | |
| 
 | |
|             // add object group if available
 | |
|             if (array_key_exists($id, $this->mappedObjects)) {
 | |
|                 $key                        = $this->mappedObjects[$id];
 | |
|                 $meta['object_group_id']    = (string)$objectGroups[$key]['id'];
 | |
|                 $meta['object_group_title'] = $objectGroups[$key]['title'];
 | |
|                 $meta['object_group_order'] = $objectGroups[$key]['order'];
 | |
|             }
 | |
| 
 | |
|             // Add notes if available.
 | |
|             if (array_key_exists($item->id, $notes)) {
 | |
|                 $meta['notes'] = $notes[$item->id];
 | |
|             }
 | |
| 
 | |
|             $item->amounts = $amounts;
 | |
|             $item->meta    = $meta;
 | |
| 
 | |
|             return $item;
 | |
|         });
 | |
| 
 | |
|         return $collection;
 | |
|     }
 | |
| 
 | |
|     public function enrichSingle(array|Model $model): array|Model
 | |
|     {
 | |
|         Log::debug(__METHOD__);
 | |
|         $collection = new Collection()->push($model);
 | |
|         $collection = $this->enrich($collection);
 | |
| 
 | |
|         return $collection->first();
 | |
|     }
 | |
| 
 | |
|     private function collectNotes(): void
 | |
|     {
 | |
|         $notes = Note::query()->whereIn('noteable_id', $this->subscriptionIds)
 | |
|             ->whereNotNull('notes.text')
 | |
|             ->where('notes.text', '!=', '')
 | |
|             ->where('noteable_type', Bill::class)->get(['notes.noteable_id', 'notes.text'])->toArray()
 | |
|         ;
 | |
|         foreach ($notes as $note) {
 | |
|             $this->notes[(int)$note['noteable_id']] = (string)$note['text'];
 | |
|         }
 | |
|         Log::debug(sprintf('Enrich with %d note(s)', count($this->notes)));
 | |
|     }
 | |
| 
 | |
|     public function setUser(User $user): void
 | |
|     {
 | |
|         $this->user      = $user;
 | |
|         $this->userGroup = $user->userGroup;
 | |
|     }
 | |
| 
 | |
|     public function setUserGroup(UserGroup $userGroup): void
 | |
|     {
 | |
|         $this->userGroup = $userGroup;
 | |
|     }
 | |
| 
 | |
|     private function collectSubscriptionIds(): void
 | |
|     {
 | |
|         /** @var Bill $bill */
 | |
|         foreach ($this->collection as $bill) {
 | |
|             $this->subscriptionIds[] = (int)$bill->id;
 | |
|         }
 | |
|         $this->subscriptionIds = array_unique($this->subscriptionIds);
 | |
|     }
 | |
| 
 | |
|     private function collectObjectGroups(): void
 | |
|     {
 | |
|         $set    = DB::table('object_groupables')
 | |
|             ->whereIn('object_groupable_id', $this->subscriptionIds)
 | |
|             ->where('object_groupable_type', Bill::class)
 | |
|             ->get(['object_groupable_id', 'object_group_id'])
 | |
|         ;
 | |
| 
 | |
|         $ids    = array_unique($set->pluck('object_group_id')->toArray());
 | |
| 
 | |
|         foreach ($set as $entry) {
 | |
|             $this->mappedObjects[(int)$entry->object_groupable_id] = (int)$entry->object_group_id;
 | |
|         }
 | |
| 
 | |
|         $groups = ObjectGroup::whereIn('id', $ids)->get(['id', 'title', 'order'])->toArray();
 | |
|         foreach ($groups as $group) {
 | |
|             $group['id']                           = (int)$group['id'];
 | |
|             $group['order']                        = (int)$group['order'];
 | |
|             $this->objectGroups[(int)$group['id']] = $group;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function collectPaidDates(): void
 | |
|     {
 | |
|         $this->paidDates = [];
 | |
|         Log::debug('Now in collectPaidDates for bills');
 | |
|         if (!$this->start instanceof Carbon || !$this->end instanceof Carbon) {
 | |
|             Log::debug('Parameters are NULL, set empty array');
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // 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->start;
 | |
|         $searchStart     = clone $start;
 | |
|         $start->subDay();
 | |
| 
 | |
|         /** @var Carbon $end */
 | |
|         $end             = clone $this->end;
 | |
|         $searchEnd       = clone $end;
 | |
| 
 | |
|         // move the search dates to the start of the day.
 | |
|         $searchStart->startOfDay();
 | |
|         $searchEnd->endOfDay();
 | |
| 
 | |
|         Log::debug(sprintf('Search parameters are: start: %s, end: %s', $searchStart->format('Y-m-d H:i:s'), $searchEnd->format('Y-m-d H:i:s')));
 | |
| 
 | |
|         // Get from database when bills were paid.
 | |
|         $set             = $this->user->transactionJournals()
 | |
|             ->whereIn('bill_id', $this->subscriptionIds)
 | |
|             ->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($searchEnd)->after($searchStart)->get(
 | |
|                 [
 | |
|                     'transaction_journals.id',
 | |
|                     'transaction_journals.date',
 | |
|                     'transaction_journals.bill_id',
 | |
|                     'transaction_journals.transaction_group_id',
 | |
|                     'transactions.transaction_currency_id',
 | |
|                     'currency.code AS transaction_currency_code',
 | |
|                     'currency.symbol AS transaction_currency_symbol',
 | |
|                     'currency.decimal_places AS transaction_currency_decimal_places',
 | |
|                     'transactions.foreign_currency_id',
 | |
|                     'foreign_currency.code AS foreign_currency_code',
 | |
|                     'foreign_currency.symbol AS foreign_currency_symbol',
 | |
|                     'foreign_currency.decimal_places AS foreign_currency_decimal_places',
 | |
|                     'transactions.amount',
 | |
|                     'transactions.foreign_amount',
 | |
|                 ]
 | |
|             )
 | |
|         ;
 | |
|         Log::debug(sprintf('Count %d entries in set', $set->count()));
 | |
| 
 | |
|         // for each bill, do a loop.
 | |
|         $converter       = new ExchangeRateConverter();
 | |
| 
 | |
|         /** @var Bill $subscription */
 | |
|         foreach ($this->collection as $subscription) {
 | |
|             // Grab from array the most recent payment. If none exist, fall back to the start date and pretend *that* was the last paid date.
 | |
|             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($subscription, $set, $start);
 | |
|             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.
 | |
|             $result                                  = [];
 | |
|             $filtered                                = $set->filter(fn (TransactionJournal $journal) => (int)$journal->bill_id === (int)$subscription->id);
 | |
|             foreach ($filtered as $entry) {
 | |
|                 $array    = [
 | |
|                     'transaction_group_id'            => (string)$entry->transaction_group_id,
 | |
|                     'transaction_journal_id'          => (string)$entry->id,
 | |
|                     'date'                            => $entry->date->toAtomString(),
 | |
|                     'date_object'                     => $entry->date,
 | |
|                     'subscription_id'                 => (string)$entry->bill_id,
 | |
|                     'currency_id'                     => (string)$entry->transaction_currency_id,
 | |
|                     'currency_code'                   => $entry->transaction_currency_code,
 | |
|                     'currency_symbol'                 => $entry->transaction_currency_symbol,
 | |
|                     'currency_decimal_places'         => $entry->transaction_currency_decimal_places,
 | |
|                     'primary_currency_id'             => (string)$this->primaryCurrency->id,
 | |
|                     'primary_currency_code'           => $this->primaryCurrency->code,
 | |
|                     'primary_currency_symbol'         => $this->primaryCurrency->symbol,
 | |
|                     'primary_currency_decimal_places' => $this->primaryCurrency->decimal_places,
 | |
|                     'amount'                          => Steam::bcround($entry->amount, $entry->transaction_currency_decimal_places),
 | |
|                     'pc_amount'                       => null,
 | |
|                     'foreign_amount'                  => null,
 | |
|                     'pc_foreign_amount'               => null,
 | |
| 
 | |
|                 ];
 | |
|                 if (null !== $entry->foreign_amount && null !== $entry->foreign_currency_code) {
 | |
|                     $array['foreign_currency_id']             = (string)$entry->foreign_currency_id;
 | |
|                     $array['foreign_currency_code']           = $entry->foreign_currency_code;
 | |
|                     $array['foreign_currency_symbol']         = $entry->foreign_currency_symbol;
 | |
|                     $array['foreign_currency_decimal_places'] = $entry->foreign_currency_decimal_places;
 | |
|                     $array['foreign_amount']                  = Steam::bcround($entry->foreign_amount, $entry->foreign_currency_decimal_places);
 | |
|                 }
 | |
|                 // convert to primary, but is already primary.
 | |
|                 if ($this->convertToPrimary && (int)$entry->transaction_currency_id === $this->primaryCurrency->id) {
 | |
|                     $array['pc_amount'] = $array['amount'];
 | |
|                 }
 | |
|                 // convert to primary, but is NOT already primary.
 | |
|                 if ($this->convertToPrimary && (int)$entry->transaction_currency_id !== $this->primaryCurrency->id) {
 | |
|                     $array['pc_amount'] = $converter->convert($entry->transactionCurrency, $this->primaryCurrency, $entry->date, $entry->amount);
 | |
|                 }
 | |
|                 // convert to primary, but foreign is already primary.
 | |
|                 if ($this->convertToPrimary && (int)$entry->foreign_currency_id === $this->primaryCurrency->id) {
 | |
|                     $array['pc_foreign_amount'] = $array['foreign_amount'];
 | |
|                 }
 | |
|                 // convert to primary, but foreign is NOT already primary.
 | |
|                 if ($this->convertToPrimary && null !== $entry->foreign_currency_id && (int)$entry->foreign_currency_id !== $this->primaryCurrency->id) {
 | |
|                     // TODO this is very database intensive.
 | |
|                     /** @var TransactionCurrency $foreignCurrency */
 | |
|                     $foreignCurrency            = Amount::getTransactionCurrencyById($entry->foreign_currency_id);
 | |
|                     $array['pc_foreign_amount'] = $converter->convert($foreignCurrency, $this->primaryCurrency, $entry->date, $entry->amount);
 | |
|                 }
 | |
|                 $result[] = $array;
 | |
|             }
 | |
|             $this->paidDates[(int)$subscription->id] = $result;
 | |
|         }
 | |
| 
 | |
|     }
 | |
| 
 | |
|     public function setStart(?Carbon $start): void
 | |
|     {
 | |
|         $this->start = $start;
 | |
|     }
 | |
| 
 | |
|     public function setEnd(?Carbon $end): void
 | |
|     {
 | |
|         $this->end = $end;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Returns the latest date in the set, or start when set is empty.
 | |
|      */
 | |
|     protected function lastPaidDate(Bill $subscription, Collection $dates, Carbon $default): Carbon
 | |
|     {
 | |
|         $filtered = $dates->filter(fn (TransactionJournal $journal) => (int)$journal->bill_id === (int)$subscription->id);
 | |
|         Log::debug(sprintf('Filtered down from %d to %d entries for bill #%d.', $dates->count(), $filtered->count(), $subscription->id));
 | |
|         if (0 === $filtered->count()) {
 | |
|             return $default;
 | |
|         }
 | |
| 
 | |
|         $latest   = $filtered->first()->date;
 | |
| 
 | |
|         /** @var TransactionJournal $journal */
 | |
|         foreach ($filtered as $journal) {
 | |
|             if ($journal->date->gte($latest)) {
 | |
|                 $latest = $journal->date;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $latest;
 | |
|     }
 | |
| 
 | |
|     private function getLastPaidDate(array $paidData): ?Carbon
 | |
|     {
 | |
|         // Log::debug('getLastPaidDate()');
 | |
|         $return = null;
 | |
|         foreach ($paidData as $entry) {
 | |
|             if (null !== $return) {
 | |
|                 /** @var Carbon $current */
 | |
|                 $current = $entry['date_object'];
 | |
|                 if ($current->gt($return)) {
 | |
|                     $return = clone $current;
 | |
|                 }
 | |
|                 Log::debug(sprintf('[a] Last paid date is: %s', $return->format('Y-m-d')));
 | |
|             }
 | |
|             if (null === $return) {
 | |
|                 /** @var Carbon $return */
 | |
|                 $return = $entry['date_object'];
 | |
|                 Log::debug(sprintf('[b] Last paid date is: %s', $return->format('Y-m-d')));
 | |
|             }
 | |
|         }
 | |
|         // Log::debug(sprintf('[c] Last paid date is: "%s"', $return?->format('Y-m-d')));
 | |
| 
 | |
|         return $return;
 | |
|     }
 | |
| 
 | |
|     private function collectPayDates(): void
 | |
|     {
 | |
|         if (!$this->start instanceof Carbon || !$this->end instanceof Carbon) {
 | |
|             Log::debug('Parameters are NULL, set empty array');
 | |
| 
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         /** @var Bill $subscription */
 | |
|         foreach ($this->collection as $subscription) {
 | |
|             $id                  = (int)$subscription->id;
 | |
|             $lastPaidDate        = $this->getLastPaidDate($this->paidDates[$id] ?? []);
 | |
|             $payDates            = $this->calculator->getPayDates($this->start, $this->end, $subscription->date, $subscription->repeat_freq, $subscription->skip, $lastPaidDate);
 | |
|             $payDatesFormatted   = [];
 | |
|             foreach ($payDates as $string) {
 | |
|                 $date                = Carbon::createFromFormat('!Y-m-d', $string, config('app.timezone'));
 | |
|                 if (!$date instanceof Carbon) {
 | |
|                     $date = today(config('app.timezone'));
 | |
|                 }
 | |
|                 $payDatesFormatted[] = $date->toAtomString();
 | |
|             }
 | |
|             $this->payDates[$id] = $payDatesFormatted;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function filterPaidDates(array $entries): array
 | |
|     {
 | |
|         return array_map(function (array $entry) {
 | |
|             unset($entry['date_object']);
 | |
| 
 | |
|             return $entry;
 | |
|         }, $entries);
 | |
|     }
 | |
| 
 | |
|     private function getNextExpectedMatch(array $payDates): ?Carbon
 | |
|     {
 | |
|         // next expected match
 | |
|         $nem          = null;
 | |
|         $firstPayDate = $payDates[0] ?? null;
 | |
| 
 | |
|         if (null !== $firstPayDate) {
 | |
|             try {
 | |
|                 $nemDate = Carbon::parse($firstPayDate, config('app.timezone'));
 | |
|             } catch (InvalidFormatException) {
 | |
|                 $nemDate = today(config('app.timezone'));
 | |
|             }
 | |
|             $nem = $nemDate;
 | |
| 
 | |
|             // nullify again when it's outside the current view range.
 | |
|             if (
 | |
|                 ($this->start instanceof Carbon && $nemDate->lt($this->start))
 | |
|                 || ($this->end instanceof Carbon && $nemDate->gt($this->end))
 | |
|             ) {
 | |
|                 $nem          = null;
 | |
|                 $nemDate      = null;
 | |
|                 $firstPayDate = null;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $nem;
 | |
|     }
 | |
| 
 | |
|     private function getNextExpectedMatchDiff(?Carbon $nem, array $payDates): string
 | |
|     {
 | |
|         if (!$nem instanceof Carbon) {
 | |
|             return trans('firefly.not_expected_period');
 | |
|         }
 | |
|         $nemDiff = trans('firefly.not_expected_period');
 | |
|         // converting back and forth is bad code but OK.
 | |
|         if ($nem->isToday()) {
 | |
|             $nemDiff = trans('firefly.today');
 | |
|         }
 | |
| 
 | |
|         $current = $payDates[0] ?? null;
 | |
|         if (null !== $current && !$nem->isToday()) {
 | |
|             try {
 | |
|                 $temp2 = Carbon::parse($current, config('app.timezone'));
 | |
|             } catch (InvalidFormatException) {
 | |
|                 $temp2 = today(config('app.timezone'));
 | |
|             }
 | |
|             $nemDiff = trans('firefly.bill_expected_date', ['date' => $temp2->diffForHumans(today(config('app.timezone')), CarbonInterface::DIFF_RELATIVE_TO_NOW)]);
 | |
|         }
 | |
|         unset($temp2);
 | |
| 
 | |
|         return $nemDiff;
 | |
|     }
 | |
| }
 |