Procházet zdrojové kódy

feat: add value object

Gustavo Mantovani před 1 dnem
rodič
revize
1879374303

+ 24 - 0
app/Casts/CpfCast.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Casts;
+
+use App\ValueObjects\Cpf;
+use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
+use Illuminate\Database\Eloquent\Model;
+
+class CpfCast implements CastsAttributes
+{
+    public function get(Model $model, string $key, mixed $value, array $attributes): ?Cpf
+    {
+        return $value === null ? null : new Cpf((string) $value);
+    }
+
+    public function set(Model $model, string $key, mixed $value, array $attributes): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        return ($value instanceof Cpf ? $value : new Cpf((string) $value))->value();
+    }
+}

+ 2 - 1
app/Http/Requests/StudentRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Foundation\Http\FormRequest;
 
 class StudentRequest extends FormRequest
@@ -29,7 +30,7 @@ public function rules(): array
             'responsible'                   => 'sometimes|nullable|array',
             'responsible.name'              => 'sometimes|nullable|string|max:255',
             'responsible.birth_date'        => 'sometimes|nullable|date',
-            'responsible.cpf'               => 'sometimes|nullable|string|max:20',
+            'responsible.cpf'               => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule()],
             'responsible.gender'            => 'sometimes|nullable|string|in:no_preference,male,female,other',
             'responsible.degree'            => 'sometimes|nullable|string|max:255',
             'responsible.email'             => 'sometimes|nullable|email',

+ 2 - 1
app/Http/Requests/StudentResponsibleRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Foundation\Http\FormRequest;
 
 class StudentResponsibleRequest extends FormRequest
@@ -12,7 +13,7 @@ public function rules(): array
             'student_id'     => 'required|exists:students,id',
             'name'           => 'required|string|max:255',
             'birth_date'     => 'required|date',
-            'cpf'            => 'required|string|max:20',
+            'cpf'            => ['required', 'string', 'max:20', Cpf::rule()],
             'gender'         => 'nullable|string|in:male,female,other,no_preference',
             'degree'         => 'required|string|max:255',
             'email'          => 'required|email|max:255',

+ 3 - 2
app/Http/Requests/UnitPartnerRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Foundation\Http\FormRequest;
 
 class UnitPartnerRequest extends FormRequest
@@ -15,7 +16,7 @@ public function rules(): array
             'name'             => 'sometimes|required|string|max:255',
             'social_name'      => 'sometimes|nullable|string|max:255',
             'role'             => 'sometimes|nullable|string|max:100',
-            'cpf'              => 'sometimes|required|string|max:20',
+            'cpf'              => ['sometimes', 'required', 'string', 'max:20', Cpf::rule()],
             'rg'               => 'sometimes|nullable|string|max:30',
             'birth_date'       => 'sometimes|nullable|date',
             'participation'    => 'sometimes|nullable|numeric|min:0|max:100',
@@ -36,7 +37,7 @@ public function rules(): array
         if ($isCreate) {
             $rules['unit_id'] = 'required|integer|exists:units,id';
             $rules['name']    = 'required|string|max:255';
-            $rules['cpf']     = 'required|string|max:20';
+            $rules['cpf']     = ['required', 'string', 'max:20', Cpf::rule()];
         }
 
         return $rules;

+ 2 - 1
app/Http/Requests/UnitRequest.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Requests;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Foundation\Http\FormRequest;
 
 class UnitRequest extends FormRequest
@@ -34,7 +35,7 @@ public function rules(): array
 
             'partners'                   => 'sometimes|nullable|array',
             'partners.*.name'            => 'required|string|max:255',
-            'partners.*.cpf'             => 'required|string|max:20',
+            'partners.*.cpf'             => ['required', 'string', 'max:20', Cpf::rule()],
             'partners.*.social_name'     => 'nullable|string|max:255',
             'partners.*.role'            => 'nullable|string|max:100',
             'partners.*.rg'              => 'nullable|string|max:30',

+ 5 - 3
app/Http/Requests/UserRequest.php

@@ -2,11 +2,13 @@
 
 namespace App\Http\Requests;
 
-use Illuminate\Foundation\Http\FormRequest;
 use App\Enums\{
+    LanguageEnum,
     UserTypeEnum,
-    LanguageEnum
 };
+
+use App\ValueObjects\Cpf;
+use Illuminate\Foundation\Http\FormRequest;
 use Illuminate\Validation\Rule;
 
 class UserRequest extends FormRequest
@@ -23,7 +25,7 @@ public function rules(): array
         $rules = [
             'avatar'    => 'sometimes|nullable|image|max:2048',
             'name'      => 'sometimes|string|nullable|max:255',
-            'cpf'       => 'sometimes|nullable|string|max:14',
+            'cpf'       => ['sometimes', 'nullable', 'string', 'max:14', Cpf::rule()],
             'phone'     => 'sometimes|nullable|string|max:20',
             'email'     => 'sometimes|email',
             'password'  => 'sometimes|string|nullable|min:8',

+ 3 - 1
app/Models/Franchisee.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -10,7 +11,7 @@
 /**
  * @property int $id
  * @property string $name
- * @property string|null $cpf
+ * @property Cpf|null $cpf
  * @property string|null $rg
  * @property string|null $cnpj
  * @property string|null $phone
@@ -61,6 +62,7 @@ class Franchisee extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
+        'cpf'        => Cpf::class,
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
         'deleted_at' => 'datetime',

+ 3 - 1
app/Models/StudentResponsible.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +13,7 @@
  * @property int $student_id
  * @property string $name
  * @property \Illuminate\Support\Carbon $birth_date
- * @property string $cpf
+ * @property Cpf $cpf
  * @property string|null $gender
  * @property string $degree
  * @property string $email
@@ -68,6 +69,7 @@ class StudentResponsible extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
+        'cpf'        => Cpf::class,
         'birth_date' => 'date',
         'created_at' => 'datetime',
         'updated_at' => 'datetime',

+ 7 - 5
app/Models/UnitPartner.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\ValueObjects\Cpf;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,7 +14,7 @@
  * @property string $name
  * @property string|null $social_name
  * @property string|null $role
- * @property string $cpf
+ * @property Cpf $cpf
  * @property string|null $rg
  * @property \Illuminate\Support\Carbon|null $birth_date
  * @property numeric|null $participation
@@ -76,11 +77,12 @@ class UnitPartner extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
-        'birth_date'  => 'date',
+        'cpf'           => Cpf::class,
+        'birth_date'    => 'date',
         'participation' => 'decimal:2',
-        'created_at'  => 'datetime',
-        'updated_at'  => 'datetime',
-        'deleted_at'  => 'datetime',
+        'created_at'    => 'datetime',
+        'updated_at'    => 'datetime',
+        'deleted_at'    => 'datetime',
     ];
 
     public function unit(): BelongsTo

+ 4 - 2
app/Models/User.php

@@ -5,6 +5,7 @@
 use App\Enums\LanguageEnum;
 use App\Enums\UserTypeEnum;
 use App\Traits\HasPermissions;
+use App\ValueObjects\Cpf;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
@@ -24,7 +25,7 @@
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property string|null $deleted_at
  * @property \Illuminate\Support\Carbon|null $last_login_at
- * @property string|null $cpf
+ * @property Cpf|null $cpf
  * @property string|null $avatar_url
  * @property int|null $state_id
  * @property string|null $phone
@@ -87,7 +88,7 @@ protected function casts(): array
             "email_verified_at" => "datetime",
             "last_login_at"     => "datetime",
             "password"          => "hashed",
-
+            "cpf"               => Cpf::class,
             "language"          => LanguageEnum::class,
         ];
     }
@@ -105,6 +106,7 @@ public function userType(): \Illuminate\Database\Eloquent\Relations\BelongsTo
     public function activeUserType(): ?UserType
     {
         $unitId = request()->input('active_unit_id');
+
         if (!$unitId && $this->access_scope === 'unit') {
             $unitId = $this->units()->value('units.id');
         }

+ 95 - 0
app/ValueObjects/Cpf.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace App\ValueObjects;
+
+use App\Casts\CpfCast;
+use Closure;
+use Illuminate\Contracts\Database\Eloquent\Castable;
+use InvalidArgumentException;
+use JsonSerializable;
+use Stringable;
+
+final readonly class Cpf implements Castable, JsonSerializable, Stringable
+{
+    private string $value;
+
+    public function __construct(string $value)
+    {
+        $digits = preg_replace('/\D/', '', $value);
+
+        if ($digits === null || ! self::isValid($digits)) {
+            throw new InvalidArgumentException(__('validation.cpf'));
+        }
+
+        $this->value = $digits;
+    }
+
+    public static function castUsing(array $arguments): string
+    {
+        return CpfCast::class;
+    }
+
+    public static function rule(): Closure
+    {
+        return static function (string $attribute, mixed $value, Closure $fail): void {
+            if (! is_string($value)) {
+                $fail(__('validation.cpf'));
+
+                return;
+            }
+
+            try {
+                new self($value);
+            } catch (InvalidArgumentException) {
+                $fail(__('validation.cpf'));
+            }
+        };
+    }
+
+    public function value(): string
+    {
+        return $this->value;
+    }
+
+    public function formatted(): string
+    {
+        return preg_replace(
+            '/(\d{3})(\d{3})(\d{3})(\d{2})/',
+            '$1.$2.$3-$4',
+            $this->value,
+        );
+    }
+
+    public function __toString(): string
+    {
+        return $this->value;
+    }
+
+    public function jsonSerialize(): string
+    {
+        return $this->value;
+    }
+
+    private static function isValid(string $digits): bool
+    {
+        if (strlen($digits) !== 11 || preg_match('/^(\d)\1{10}$/', $digits)) {
+            return false;
+        }
+
+        for ($position = 9; $position < 11; $position++) {
+            $sum = 0;
+
+            for ($index = 0; $index < $position; $index++) {
+                $sum += (int) $digits[$index] * (($position + 1) - $index);
+            }
+
+            $checkDigit = (($sum * 10) % 11) % 10;
+
+            if ((int) $digits[$position] !== $checkDigit) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+}

+ 1 - 0
lang/en/validation.php

@@ -36,6 +36,7 @@
     'cannot_delete_related' => 'The :attribute field cannot be deleted because it has related records.',
     'confirmed' => 'The :attribute field confirmation does not match.',
     'contains' => 'The :attribute field is missing a required value.',
+    'cpf' => 'The provided CPF is invalid.',
     'current_password' => 'The password is incorrect.',
     'date' => 'The :attribute field must be a valid date.',
     'date_equals' => 'The :attribute field must be a date equal to :date.',

+ 1 - 0
lang/es/validation.php

@@ -36,6 +36,7 @@
     'cannot_delete_related' => 'No se puede eliminar el registro porque tiene registros relacionados.',
     'confirmed' => 'La confirmación del campo :attribute no coincide.',
     'contains' => 'El campo :attribute carece de un valor requerido.',
+    'cpf' => 'El CPF proporcionado no es válido.',
     'current_password' => 'La contraseña es incorrecta.',
     'date' => 'El campo :attribute debe ser una fecha válida.',
     'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',

+ 1 - 0
lang/pt/validation.php

@@ -37,6 +37,7 @@
     'cannot_delete_related' => 'Não é possível excluir o :attribute porque existem registros relacionados.',
     'confirmed' => 'A confirmação do campo :attribute não corresponde.',
     'contains' => 'O campo :attribute está faltando um valor obrigatório.',
+    'cpf' => 'O CPF informado é inválido.',
     'current_password' => 'A senha está incorreta.',
     'date' => 'O campo :attribute deve ser uma data válida.',
     'date_equals' => 'O campo :attribute deve ser uma data igual a :date.',

+ 77 - 0
tests/Unit/ValueObjects/CpfTest.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Unit\ValueObjects;
+
+use App\Models\Franchisee;
+use App\Models\StudentResponsible;
+use App\Models\UnitPartner;
+use App\Models\User;
+use App\ValueObjects\Cpf;
+use Illuminate\Support\Facades\Validator;
+use InvalidArgumentException;
+use Tests\TestCase;
+
+class CpfTest extends TestCase
+{
+    public function test_it_accepts_and_normalizes_a_valid_cpf(): void
+    {
+        $cpf = new Cpf('529.982.247-25');
+
+        $this->assertSame('52998224725', $cpf->value());
+        $this->assertSame('529.982.247-25', $cpf->formatted());
+        $this->assertSame('52998224725', (string) $cpf);
+        $this->assertSame('"52998224725"', json_encode($cpf));
+    }
+
+    public function test_it_rejects_an_invalid_cpf(): void
+    {
+        app()->setLocale('pt');
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('O CPF informado é inválido.');
+
+        new Cpf('111.111.111-11');
+    }
+
+    public function test_its_request_rule_validates_cpf(): void
+    {
+        $valid = Validator::make(
+            ['cpf' => '529.982.247-25'],
+            ['cpf' => ['required', Cpf::rule()]],
+        );
+        $invalid = Validator::make(
+            ['cpf' => '529.982.247-24'],
+            ['cpf' => ['required', Cpf::rule()]],
+        );
+
+        $this->assertTrue($valid->passes());
+        $this->assertTrue($invalid->fails());
+    }
+
+    public function test_its_request_rule_uses_the_current_locale(): void
+    {
+        app()->setLocale('es');
+
+        $validator = Validator::make(
+            ['cpf' => '529.982.247-24'],
+            ['cpf' => [Cpf::rule()]],
+        );
+
+        $this->assertSame(
+            'El CPF proporcionado no es válido.',
+            $validator->errors()->first('cpf'),
+        );
+    }
+
+    public function test_models_cast_cpf_to_the_value_object(): void
+    {
+        foreach ([Franchisee::class, StudentResponsible::class, UnitPartner::class, User::class] as $modelClass) {
+            $model = new $modelClass;
+
+            $model->cpf = '529.982.247-25';
+
+            $this->assertInstanceOf(Cpf::class, $model->cpf);
+            $this->assertSame('52998224725', $model->getAttributes()['cpf']);
+        }
+    }
+}