whisper-money/app/Services/Ai/UncategorizedTransactionMat...

143 lines
4.1 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\Contracts\TransactionMatcher;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class UncategorizedTransactionMatcher implements TransactionMatcher
{
/**
* Fields a suggested rule is allowed to match against (server-readable,
* never the encrypted description/notes blobs).
*/
public const ALLOWED_FIELDS = ['description', 'creditor_name', 'debtor_name'];
public function total(User $user): int
{
return $this->baseQuery($user)->count();
}
public function countMatching(User $user, string $field, string $operator, string $token): int
{
$query = $this->matchQuery($user, $field, $operator, $token);
return $query === null ? 0 : $query->count();
}
public function matching(User $user, string $field, string $operator, string $token, ?int $limit = null): Collection
{
$query = $this->matchQuery($user, $field, $operator, $token);
if ($query === null) {
return new Collection;
}
$query->orderByDesc('transaction_date')->orderByDesc('id');
if ($limit !== null) {
$query->limit($limit);
}
return $query->get();
}
public function countMatchingAny(User $user, array $conditions): int
{
$query = $this->anyQuery($user, $conditions);
return $query === null ? 0 : $query->count();
}
public function matchingAny(User $user, array $conditions, ?int $limit = null): Collection
{
$query = $this->anyQuery($user, $conditions);
if ($query === null) {
return new Collection;
}
$query->orderByDesc('transaction_date')->orderByDesc('id');
if ($limit !== null) {
$query->limit($limit);
}
return $query->get();
}
/**
* @return Builder<Transaction>
*/
private function baseQuery(User $user): Builder
{
return Transaction::query()
->where('user_id', $user->id)
->whereNull('category_id')
->whereNull('description_iv');
}
/**
* @return Builder<Transaction>|null
*/
private function matchQuery(User $user, string $field, string $operator, string $token): ?Builder
{
if (! in_array($field, self::ALLOWED_FIELDS, true) || trim($token) === '') {
return null;
}
$query = $this->baseQuery($user);
$token = mb_strtolower(trim($token));
if ($operator === 'equals') {
return $query->whereRaw("LOWER({$field}) = ?", [$token]);
}
return $query->whereRaw("LOWER({$field}) LIKE ?", ['%'.$this->escapeLike($token).'%']);
}
/**
* Build a single query matching ANY of the conditions (OR). Invalid
* conditions (unknown field or blank token) are skipped; returns null when
* none remain.
*
* @param list<array{field: string, operator: string, token: string}> $conditions
* @return Builder<Transaction>|null
*/
private function anyQuery(User $user, array $conditions): ?Builder
{
$valid = array_values(array_filter(
$conditions,
fn (array $condition): bool => in_array($condition['field'], self::ALLOWED_FIELDS, true)
&& trim($condition['token']) !== '',
));
if ($valid === []) {
return null;
}
return $this->baseQuery($user)->where(function (Builder $builder) use ($valid): void {
foreach ($valid as $condition) {
$field = $condition['field'];
$token = mb_strtolower(trim($condition['token']));
if ($condition['operator'] === 'equals') {
$builder->orWhereRaw("LOWER({$field}) = ?", [$token]);
continue;
}
$builder->orWhereRaw("LOWER({$field}) LIKE ?", ['%'.$this->escapeLike($token).'%']);
}
});
}
private function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}
}