Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
19 / 19
CRAP
100.00% covered (success)
100.00%
1 / 1
Trip
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
19 / 19
23
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 transferOwnershipTo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ownerId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isOwnedBy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 destination
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dateRange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 currency
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 budget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createdAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 imageName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 changeImage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 archive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isArchived
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 assertBudget
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Trip\Domain;
6
7use App\Shared\Domain\ValueObject\Currency;
8use App\Shared\Domain\ValueObject\DateRange;
9use App\Shared\Domain\ValueObject\Money;
10use App\Trip\Domain\ValueObject\OwnerId;
11use App\Trip\Domain\ValueObject\TripId;
12use DateTimeImmutable;
13use Doctrine\ORM\Mapping as ORM;
14use InvalidArgumentException;
15
16#[ORM\Entity]
17#[ORM\Table(name: 'trips')]
18final class Trip
19{
20    private function __construct(
21        #[ORM\Id]
22        #[ORM\Column(type: 'trip_id')]
23        private readonly TripId $id,
24        #[ORM\Column(name: 'owner_id', type: 'owner_id')]
25        private OwnerId $ownerId,
26        #[ORM\Column]
27        private string $name,
28        #[ORM\Column]
29        private string $destination,
30        #[ORM\Column(name: 'start_date', type: 'date_immutable')]
31        private DateTimeImmutable $startDate,
32        #[ORM\Column(name: 'end_date', type: 'date_immutable')]
33        private DateTimeImmutable $endDate,
34        // A trip fixes one currency at creation; all its expenses (and its budget)
35        // are expressed in this currency. It is intentionally immutable.
36        #[ORM\Column(type: 'currency')]
37        private readonly Currency $currency,
38        #[ORM\Column(name: 'budget_amount', type: 'integer')]
39        private int $budgetAmount,
40        #[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
41        private readonly DateTimeImmutable $createdAt,
42        // Name of the banner image in the uploads root, or null when the trip has
43        // none. The trip never knows where files live — that is ImageStorage's job.
44        #[ORM\Column(name: 'image_name', nullable: true)]
45        private ?string $imageName = null,
46        // Manual, irreversible close: the owner archives a trip when it is over,
47        // so the list can hide it. Never derived from the dates.
48        #[ORM\Column]
49        private bool $archived = false,
50    ) {
51    }
52
53    public static function create(
54        TripId $id,
55        OwnerId $ownerId,
56        string $name,
57        string $destination,
58        DateRange $dates,
59        Currency $currency,
60        Money $budget,
61    ): self {
62        [$name, $destination] = self::sanitizeText($name, $destination);
63        self::assertBudget($currency, $budget);
64
65        return new self(
66            $id,
67            $ownerId,
68            $name,
69            $destination,
70            $dates->start(),
71            $dates->end(),
72            $currency,
73            $budget->amount(),
74            new DateTimeImmutable(),
75        );
76    }
77
78    /**
79     * Mutate the editable attributes. Currency stays fixed (see field comment);
80     * the budget must therefore stay in the trip currency.
81     */
82    public function update(string $name, string $destination, DateRange $dates, Money $budget): void
83    {
84        [$name, $destination] = self::sanitizeText($name, $destination);
85        self::assertBudget($this->currency, $budget);
86
87        $this->name = $name;
88        $this->destination = $destination;
89        $this->startDate = $dates->start();
90        $this->endDate = $dates->end();
91        $this->budgetAmount = $budget->amount();
92    }
93
94    /**
95     * Hand the trip to a new owner (used when the current owner deletes their
96     * account and a member inherits it). The rest of the trip is unchanged.
97     */
98    public function transferOwnershipTo(OwnerId $newOwner): void
99    {
100        $this->ownerId = $newOwner;
101    }
102
103    public function id(): TripId
104    {
105        return $this->id;
106    }
107
108    public function ownerId(): OwnerId
109    {
110        return $this->ownerId;
111    }
112
113    public function isOwnedBy(OwnerId $ownerId): bool
114    {
115        return $this->ownerId->equals($ownerId);
116    }
117
118    public function name(): string
119    {
120        return $this->name;
121    }
122
123    public function destination(): string
124    {
125        return $this->destination;
126    }
127
128    public function dateRange(): DateRange
129    {
130        return new DateRange($this->startDate, $this->endDate);
131    }
132
133    public function currency(): Currency
134    {
135        return $this->currency;
136    }
137
138    public function budget(): Money
139    {
140        return new Money($this->budgetAmount, $this->currency);
141    }
142
143    public function createdAt(): DateTimeImmutable
144    {
145        return $this->createdAt;
146    }
147
148    public function imageName(): ?string
149    {
150        return $this->imageName;
151    }
152
153    /** Sets the banner image, or clears it with null. The old file is removed by the caller. */
154    public function changeImage(?string $imageName): void
155    {
156        $this->imageName = $imageName;
157    }
158
159    // ponytail: idempotent on purpose, archiving twice is not an error.
160    public function archive(): void
161    {
162        $this->archived = true;
163    }
164
165    public function isArchived(): bool
166    {
167        return $this->archived;
168    }
169
170    /**
171     * @return array{string, string}
172     */
173    private static function sanitizeText(string $name, string $destination): array
174    {
175        $name = trim($name);
176        $destination = trim($destination);
177
178        if ($name === '') {
179            throw new InvalidArgumentException('Trip name cannot be empty.');
180        }
181        if ($destination === '') {
182            throw new InvalidArgumentException('Trip destination cannot be empty.');
183        }
184
185        return [$name, $destination];
186    }
187
188    private static function assertBudget(Currency $currency, Money $budget): void
189    {
190        if (!$budget->currency()->equals($currency)) {
191            throw new InvalidArgumentException('Budget currency must match the trip currency.');
192        }
193        if ($budget->amount() < 0) {
194            throw new InvalidArgumentException('Budget cannot be negative.');
195        }
196    }
197}