Slide 1

Slide 1 text

Dayuse.com 
 Challenges to scale an innovative worldwide business SymfonyLive 2019 / Sept. 26th / Berlin / Germany

Slide 2

Slide 2 text

Hugo Hamon

Slide 3

Slide 3 text

https://speakerdeck.com/hhamon

Slide 4

Slide 4 text

Introduction 0

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

70 people based in Paris, USA & Hong-Kong

Slide 7

Slide 7 text

+5 000 hotels +1M bookings 25 countries

Slide 8

Slide 8 text

Main Challenges of 2018/2019 •Securing the technical stack •Improving the user experience •Aggregating more business data •Making billing more reliable •Becoming more agile!

Slide 9

Slide 9 text

Securing the Technical Stack 1

Slide 10

Slide 10 text

PHP 5.6 -> PHP 7.2 Symfony 2.6 -> 3.4

Slide 11

Slide 11 text

Exceptions Logging with Sentry.io

Slide 12

Slide 12 text

DI & Autowiring services: _defaults: autowire: true autoconfigure: true public: false bind: $mailer: '@app.mailer' $defaultCurrencyCode: '%default.currency.code%' $defaultCurrencyRateFactor: '%default.currency_rate.factor%' $commandBus: '@legacy_command_bus' $filesystemMap: '@knp_gaufrette.filesystem_map' $staticCanonicalDomain: '%domains.static.canonical_domain%' Dayuse\Bundle\FrontBundle\Controller\: resource: '../../src/Dayuse/Bundle/FrontBundle/Controller' public: true tags: ['controller.service_arguments'] Dayuse\Service\ContentCachePool: alias: 'Dayuse\Service\CacheService' # ...

Slide 13

Slide 13 text

// ... use Dayuse\Service\ContentCachePool; class CachedFooterRenderer implements FooterBlockRenderer { // ... public function __construct( FooterBlockRenderer $renderer, LanguageRepository $languageRepository, ContentCachePool $cache, int $ttl = self::DEFAULT_TTL ) { // ... } }

Slide 14

Slide 14 text

Assertions Usage final class GeoHash { // ... private static function fromArray(array $payload, string $issuer): self { Assertion::keyExists($payload, 'lat', 'Payload is missing a "lat" (latitude) key.'); Assertion::keyExists($payload, 'lng', 'Payload is missing a "lng" (longitude) key.'); Assertion::keyExists($payload, 't', 'Payload is missing a "t" (type) key.'); Assertion::keyExists($payload, 'p', 'Payload is missing a "p" (provider) key.'); Assertion::numeric($payload['lat'], 'The "lat" key must contain a valid numeric value.'); Assertion::numeric($payload['lng'], 'The "lng" key must contain a valid numeric value.'); Assertion::notEmpty($payload['p'], 'The "p" key must contain a non empty string.'); Assertion::inArray($payload['t'], AddressComponents::TYPES); return new self($payload['lat'], $payload['lng'], $payload['t'], $payload['p'], $issuer); } }

Slide 15

Slide 15 text

•Country •DateRange •DateTimeRange •Email •Money •PriceRange •Time •TimeSlot •etc. Value Objects

Slide 16

Slide 16 text

final class DateRange implements \IteratorAggregate { private $from; private $until; public static function between(string $from, string $to): self { return new self( new \DateTimeImmutable(date('Y-m-d', strtotime($from))), new \DateTimeImmutable(date('Y-m-d', strtotime($to))) ); } 
 // ... public function toListOfDates(string $format = 'Y-m-d', bool $appendEndDate = true): array { $dates = []; foreach ($this->getDays() as $day) { $dates[] = $day->format($format); } $endDate = $this->until->format($format); if ($appendEndDate && !\in_array($endDate, $dates, true)) { $dates[] = $endDate; } return $dates; } }

Slide 17

Slide 17 text

class RoomSlotRepository extends ServiceEntityRepository { // ... public function getAvailabilities(Room $room, DateRange $dates): array { $query = $this->createQueryBuilder('s'); return $query ->andWhere('s.roomId = :roomId') ->setParameter('roomId', $room->getId()) ->andWhere($query->expr()->in('s.date', ':dates')) ->setParameter('dates', $dates->toListOfDates()) ->orderBy('s.date', 'ASC') ->getQuery() ->useQueryCache(true) ->execute() ; } }

Slide 18

Slide 18 text

Custom Collections

Slide 19

Slide 19 text

class RoomAvailabilityService { // ... public function generateRoomAvailabilities(Room $room, DateRange $period): void { // ... $roomSlotList = $this->roomSlotRepository->findRoomSlotByPeriod($room, $period); // ... foreach ($availabilityPeriod as $date) { if ($roomSlot = $this->getRoomSlotScheduledOn($roomSlotList, $date)) { continue; } // ... } // ... } private function getRoomSlotScheduledOn(array $list, \DateTimeInterface $currentDate): ?RoomSlot { foreach ($list as $item) { if ($item->isScheduledOn($currentDate)) { return $item; } } return null; } }

Slide 20

Slide 20 text

final class RoomAvailabilityList implements \IteratorAggregate, \Countable { private $items; public function __construct(array $items = []) { Assertion::allIsInstanceOf($items, RoomSlot::class); $this->items = new ArrayCollection($items); } public function getAvailabilityScheduledOn(\DateTimeInterface $date): ?RoomSlot { /** @var RoomSlot $item */ foreach ($this->items as $item) { if ($item->isScheduledOn($date)) { return $item; } } return null; } // ... }

Slide 21

Slide 21 text

class RoomAvailabilityService { // ... public function generateRoomAvailabilities(Room $room, DateRange $period): void { // ... $slots = $this->roomSlotRepository->findRoomSlotByPeriod($room, $period); $availabilities = new RoomAvailabilityList($slot); // ... foreach ($period as $date) { if ($roomSlot = $availabilities->getAvailabilityScheduledOn($date)) { continue; } // ... } // ... } }

Slide 22

Slide 22 text

Gitlab CI + Quality Check Pipeline

Slide 23

Slide 23 text

use Symfony\Component\Panther\Client; use PHPUnit\Framework\TestCase; final class AnonymousBookingTest extends TestCase { private $client; protected function setUp() { $this->client = Client::createChromeClient(null, ['...'], [], 'https://fr.www.dayuse.test'); } public function testAnonymousBookingWithoutOptions(): void { $tomorrow = date('Y-m-d', strtotime('now +1 day'); $crawler = $this->client->request('GET', '/hotels/france/7hotel-fitness?check_in_date='. $tomorow); $this->assertContains( '7Hôtel & Fitness, Illkirch-Graffenstaden : -48% en journée - Dayuse.test', $crawler->filter('title')->html() ); $this->client->clickLink("J'accepte"); $crawler = $this->client->refreshCrawler(); $this->assertContains('Chambre double superieure', $crawler->filter('p.room_style')->html()); $crawler->filter('a.selectRoomForm_101589_btn-form')->click(); $this->client->waitFor('form[name="information_to_be_transmitted"]'); // ... } }

Slide 24

Slide 24 text

Monitoring HTTP requests with redirection.io

Slide 25

Slide 25 text

Developer Experience

Slide 26

Slide 26 text

Improving the User Experience 2

Slide 27

Slide 27 text

Rebuilding the Search Engine

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

No content

Slide 30

Slide 30 text

No content

Slide 31

Slide 31 text

Rebuilding the Booking Funnel

Slide 32

Slide 32 text

?

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

No content

Slide 37

Slide 37 text

No content

Slide 38

Slide 38 text

No content

Slide 39

Slide 39 text

No content

Slide 40

Slide 40 text

src/Dayuse/Bundle/FrontBundle/Controller/Booking/ ├── AbstractBookingAction.php ├── ReservationStepsAction.php ├── ReservationSummaryAction.php ├── Step1/ │ ├── ReservationOptionsAction.php │ └── UpdateOptionsAction.php ├── Step2/ │ ├── BlacklistAction.php │ └── InformationToBeTransmittedToHotelAction.php ├── Step3/ │ ├── AnonymousReservationAction.php │ ├── AnonymousReservationEmailCheckerAction.php │ ├── Confirmation/ │ │ ├── ... │ │ └── SmsConfirmationAction.php │ ├── LoginAction.php │ ├── Password/ │ │ ├── ForgotPasswordAction.php │ │ └── ResetPasswordAction.php │ └── ReservationModeAction.php ├── Step4/ │ ├── CreditCardPreAuthAction.php │ └── ValidatePreAuthAction.php └── Step5/ ├── ConfirmationAction.php └── SendSmsConfirmation.php Split a 2K LOC BookingController class into several action classes. 1 action = 1 class Actions are services

Slide 41

Slide 41 text

Split a 2K LOC BookingController class into several action classes. 1 action = 1 class Leverage DI final class ConfirmationAction extends AbstractBookingAction { /** @Route("/confirmation") */ public function __invoke(Request $request): Response { // ... $reservation = $this->loadReservation($bookingRequest->getReservationId()); $language = $this->visitorLocalizationConfiguration->getLanguage(); $hotel = $this->hotelRepository->getHotelById($reservation->getHotel()->getId()); $bookingRequestViewModelData = $this->bookingRequestViewModelBuilder->getViewData($bookingRequest); return new Response( $this->engine->render('@DayuseFront/booking/step5/confirmation.html.twig', array_merge($bookingRequestViewModelData, [ 'reservationData' => $this->reservationConverter->exposeEntity( $reservation, $language, $this->visitorLocalizationConfiguration->getCurrency() ), 'willCollectTax' => $hotel->willCollectLocalSalesTax(), 'hotelPostalCode' => $hotel->getZipCode(), 'hotelCountryCode' => $hotel->getCountryCode(), 'customer' => $this->loadCurrentCustomer($bookingRequest), 'taxInformation' => $this->hotelContentService->getDefaultTaxInformation($hotel, $language), 'defaultConditions' => $this->hotelContentService->getDefaultConditions($hotel, $language), ])) ); } }

Slide 42

Slide 42 text

Reservations with a filled ETA June 20th 2019

Slide 43

Slide 43 text

Reservations made with a registered account June 20th 2019

Slide 44

Slide 44 text

Transactional Emails

Slide 45

Slide 45 text

No content

Slide 46

Slide 46 text

No content

Slide 47

Slide 47 text

# app/config/config.yml swiftmailer: # default or failover default_mailer: ‘%default_mailer%' mailers: # Mandrillapp default: transport: smtp host: '%mailer.default.smtp.host%' username: '%mailer.default.smtp.user%' password: '%mailer.default.smtp.password%' port: '%mailer.default.smtp.port%' encryption: '%mailer.default.smtp.encryption%' delivery_addresses: '%mailer.delivery_addresses%' disable_delivery: ‘%mailer.disable_delivery%' # Amazon SES failover: transport: smtp host: '%mailer.failover.smtp.host%' username: '%mailer.failover.smtp.user%' password: '%mailer.failover.smtp.password%' port: '%mailer.failover.smtp.port%' encryption: '%mailer.failover.smtp.encryption%' delivery_addresses: '%mailer.delivery_addresses%' disable_delivery: '%mailer.disable_delivery%'

Slide 48

Slide 48 text

I18N & L10N

Slide 49

Slide 49 text

Dates, Times and Timezones 1. Ensure all your services run on UTC 2. Store ISO timezones names, not offsets 3. Use MySQL date & times functions

Slide 50

Slide 50 text

Clock Mocking class ReservationPaymentTransaction { // ... public function capture(): void { Ensure::true($this->isCapturable()); $this->capturedAt = \DateTimeImmutable::createFromFormat('U', time()); $this->status = self::STATUS_CAPTURED; $this->markUpdated(); } }

Slide 51

Slide 51 text

Clock Mocking final class ReservationPaymentTransactionTest extends TestCase { // ... public function testCannotCaptureExpiredPreauthorizedPayment(): void { ClockMock::register(ReservationPaymentTransaction::class); ClockMock::withClockMock(strtotime('2019-08-02 10:34:56')); $authorizedAt = new \DateTimeImmutable('2019-07-25 11:10:30'); $transaction = $this->initiatePaymentTransaction(Money::EUR(300), '2019-07-25 11:10:12'); $transaction->authorize('pm_1F41UtAKwwJlXkiKw3z6ZFKj', $authorizedAt); $this->expectException(EnsureFailedException::class); $this->expectExceptionMessage('Reservation payment transaction is not capturable.'); $transaction->capture(); } }

Slide 52

Slide 52 text

MySQL ConvertTZ Function SELECT id, name, timezone, DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') AS utc_time, DATE_FORMAT(CONVERT_TZ(NOW(), 'UTC', timezone), '%Y-%m-%d %H:%i:%s') AS hotel_time FROM tbl_hotel ORDER BY id DESC LIMIT 100;

Slide 53

Slide 53 text

+-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+ | id | name | timezone | utc_time | hotel_time | +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+ | 11685 | Boutique Hotel Anahi | Europe/Rome | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11684 | Hyatt Centric The Pike Long Beach | America/Los_Angeles | 2019-09-08 17:37:25 | 2019-09-08 10:37:25 | | 11682 | Hotel Konsul Bonn | Europe/Berlin | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11681 | Hallmark Hotel the Welcombe | Europe/London | 2019-09-08 17:37:25 | 2019-09-08 18:37:25 | | 11680 | Hotel Le Mount Stephen | America/Toronto | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11679 | Maritim Hotel Köln | Europe/Berlin | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11678 | Mantra Epping | Australia/Melbourne | 2019-09-08 17:37:25 | 2019-09-09 03:37:25 | | 11677 | Hôtel Saint-Christophe | Europe/Paris | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11671 | Holiday Inn Express & Suites Austin NE - Hutto | America/Chicago | 2019-09-08 17:37:25 | 2019-09-08 12:37:25 | | 11670 | Hyatt Regency Crystal City At Reagan National Airport | America/New_York | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11669 | Mont Gabriel Resort & Spa | America/Toronto | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11668 | Ocean Sky Hotel & Resort Fort Lauderdale Beach | America/New_York | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11667 | Le Nouvel Hotel & Spa | America/Toronto | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11666 | LE TSUBA HOTEL | Europe/Paris | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11665 | Aloft Miami Dadeland | America/New_York | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11664 | Quality Suites San Diego SeaWorld Area | America/Los_Angeles | 2019-09-08 17:37:25 | 2019-09-08 10:37:25 | | 11663 | The Volare, an Ascend Hotel Collection Member | America/Los_Angeles | 2019-09-08 17:37:25 | 2019-09-08 10:37:25 | | 11662 | Ten Square Hotel | Europe/London | 2019-09-08 17:37:25 | 2019-09-08 18:37:25 | | 11661 | Old West Lofts Downtown Tampa | America/New_York | 2019-09-08 17:37:25 | 2019-09-08 13:37:25 | | 11660 | Embassy Suites by Hilton Phoenix Airport | America/Phoenix | 2019-09-08 17:37:25 | 2019-09-08 10:37:25 | | 11659 | Harbour Bay Hotel | Asia/Hong_Kong | 2019-09-08 17:37:25 | 2019-09-09 01:37:25 | | 11650 | The Westin Dublin | Europe/Dublin | 2019-09-08 17:37:25 | 2019-09-08 18:37:25 | | 11649 | Hampton by Hilton Luton Airport | Europe/London | 2019-09-08 17:37:25 | 2019-09-08 18:37:25 | | 11612 | The Sukhothai Bangkok | Asia/Bangkok | 2019-09-08 17:37:25 | 2019-09-09 00:37:25 | | 11611 | Leonardo Hotel Weimar | Europe/Berlin | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | | 11610 | Leonardo Hotel Munich City East | Europe/Berlin | 2019-09-08 17:37:25 | 2019-09-08 19:37:25 | +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+

Slide 54

Slide 54 text

Select hotels where local time is 1:00 AM SELECT id, name, timezone, DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') AS utc_time, DATE_FORMAT(CONVERT_TZ(NOW(), 'UTC', timezone), '%Y-%m-%d %H:%i:%s') AS hotel_time FROM tbl_hotel WHERE HOUR(CONVERT_TZ(NOW(), 'UTC', timezone)) = 1 ORDER BY id DESC LIMIT 100;

Slide 55

Slide 55 text

+-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+ | id | name | timezone | utc_time | hotel_time | +-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+ | 11521 | Hotel Tranz | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11522 | Avani Sukhumvit Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11292 | Adelphi Grande Sukhumvit | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11553 | Dream Hotel Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11321 | Novotel Bangkok Suvarnabhumi Airport | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11332 | Park Plaza Bangkok Soi 18 | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11336 | Modena by Fraser Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11612 | The Sukhothai Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11364 | Radisson Blu Plaza Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11622 | Narai Hotel | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11623 | Triple Two Silom | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11386 | Amaranth Suvarnabhumi Airport, BW Premier Collection by Best Western | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11387 | Dynasty Grande Hotel | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11400 | Salil Hotel Sukhumvit Soi 11 | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11423 | Summit Windmill Golf Residence | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11446 | Golden Tulip Mandison Suites | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11463 | Hilton Sukhumvit Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11476 | Travelodge Sukhumvit 11 | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11477 | Eastin Thana City Golf Resort Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11478 | Eastin Hotel Makkasan Bangkok | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | | 11479 | Shanghai Mansion Bangkok Hotel | Asia/Bangkok | 2019-09-08 18:04:44 | 2019-09-09 01:04:44 | +-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+ 21 rows in set (0.02 sec)

Slide 56

Slide 56 text

Loco - https://localise.biz

Slide 57

Slide 57 text

Manage translations projects

Slide 58

Slide 58 text

No content

Slide 59

Slide 59 text

Image Management

Slide 60

Slide 60 text

• Free and Open-Source • Crop, resize & filter images • Caches and delivers images • WebP support • Symfony bundle integration

Slide 61

Slide 61 text

# app/config/config.yml parameters: thumbor_secure_key: '%thumbor.secure.key%' thumbor_server_domain: '%thumbor.server.domain%' thumbor_filters: original_scaled_down: width: 1280 height: 548 dimensions: '1280x548' thumbnail: width: 310 height: 206 dimensions: '310x206' big: width: 797 height: 533 dimensions: '797x533'

Slide 62

Slide 62 text

{{ hotelName }} Le General
Hôtel Paris 11ème

Slide 63

Slide 63 text

Aggregating Business Data 3

Slide 64

Slide 64 text

Tracking Reservations Statuses Changes 1. To which state did the booking transition? 2. Who performed the operation? 3. When did the operation happen? 4. Where did the operation happen? 5. Any extra relevant reasons to provide

Slide 65

Slide 65 text

No content

Slide 66

Slide 66 text

Tracking booking lifecycle Automatic reminders Lifecycle graph

Slide 67

Slide 67 text

Tracking booking lifecycle

Slide 68

Slide 68 text

src/Dayuse/Reservations/ └── Workflow/ ├── Abandonment.php ├── AbstractTransition.php ├── AvailabilityConfirmationRequest.php ├── AvailabilityRequestAcceptance.php ├── AvailabilityRequestRefusal.php ├── Bookout.php ├── Cancellation.php ├── Confirmation.php ├── ConfirmationAttempt.php ├── Initialization.php ├── InstantAutomaticConfirmation.php ├── NoShow.php └── Reinstatement.php

Slide 69

Slide 69 text

abstract class AbstractTransition { // ... abstract public function execute(array $context = []): void; final public function getUuid(): UuidInterface { return $this->uuid; } final public function getActor(): UserAccountIdentity { return $this->actor; } final public function getPlatformId(): int { return $this->platformId; } final public function getPlatformHostname(): ?string { return $this->platformHostname; } final public function getReasonCode(): ?string { return $this->reasonCode; } final public function getReasonMessage(): ?string { return $this->reasonMessage; } final public function getRecordedAt(): UtcDateTimeImmutable { return UtcDateTimeImmutable::createFromFormat('U', $this->recordedAt); } }

Slide 70

Slide 70 text

final class SmsConfirmationAction extends AbstractBookingAction { /** * @Route("/sms-confirmation", name="_booking_reservation_sms_confirmation") */ public function __invoke(Request $request): Response { // ... if ($form->isSubmitted() && $form->isValid()) { // ... try { $this->reservationService->attemptConfirmation(ConfirmationAttempt::create( $reservation, $this->getUser() ?: $reservation->getCustomer(), $bookingRequest->getOrigin(), $request->getHttpHost(), $request->getClientIp(), $request->headers->get('User-Agent') )); } catch (UnavailabilityException | IllegalReservationOperationException $e) { $this->bookingRequestRepository->remove(); return $this->createUnavailabilityResponse($hotel, $room); } // ... } // ... } }

Slide 71

Slide 71 text

namespace Dayuse\Entity; class Reservation { private $reservation; // ... final public function confirm(Confirmation $confirmation): void { $this->ensureSameReservation($confirmation->getReservation()); $this->status = ReservationStatus::CONFIRMED; $this->recordLifecycleActivity($confirmation, ActivityStatus::confirmed()); } }

Slide 72

Slide 72 text

class Reservation { /** * @ORM\OneToMany( * targetEntity="Dayuse\Entity\ReservationLifecycleActivity", * mappedBy="reservation", * cascade={"persist", "remove"}, * orphanRemoval=true * ) */ private $lifecycleActivities; // ... private function recordLifecycleActivity(AbstractTransition $trans, ActivityStatus $status): void { $this->lifecycleActivities->add(new ReservationLifecycleActivity( $this, $trans->getUuid(), $status, ActivityActor::fromUserAccount($trans->getActor()), Platform::fromPlatformId($trans->getPlatformId(), $trans->getPlatformHostname()), $trans->getReasonCode() ? ActivityReason::fromCode($trans->getReasonCode(), $trans->getReasonMessage()) : null , $trans->getRecordedAt() )); } }

Slide 73

Slide 73 text

Make Billing Great Again! 4

Slide 74

Slide 74 text

Many Problems • Large variety of billable items • Free trials • Adjustments • Local taxes & VAT • Credit notes • Cash collection • Multi currency support • Multi languages support • Invoice PDF generation • Invoice automatic delivery

Slide 75

Slide 75 text

Goals •Extensible •Flexible •Automated •Reliable

Slide 76

Slide 76 text

Moneytary Values « If all your calculations are done in a single currency, this isn't a huge problem, but once you involve multiple currencies you want to avoid adding your dollars to your yen without taking the currency differences into account. » « The more subtle problem is with rounding. Monetary calculations are often rounded to the smallest currency unit. When you do this it's easy to lose pennies (or your local equivalent) because of rounding errors. » https://martinfowler.com/eaaCatalog/money.html

Slide 77

Slide 77 text

Money Library class Invoice implements Billing, CreditNote { // ... public function getTotalBookingsItemPrice(): Money { $total = Money::{$this->billingCurrency}(0); foreach ($this->invoiceItems as $invoiceItem) { if ($invoiceItem instanceof CustomItem || $invoiceItem instanceof BookingFeesItem) { continue; } $total = $total->add($invoiceItem ->getItemUnitPrice() ->multiply($invoiceItem->getItemQuantity()) ); } return $total; } }

Slide 78

Slide 78 text

class Invoice implements Billing, CreditNote { // ... public function balance(): Money { Assertion::false($this->isCreditNote()); $balance = $this->getTotal(); foreach ($this->payments as $payment) { $balance = $balance->subtract($payment->getAmount()); } foreach ($this->creditNotes as $creditNote) { $balance = $balance->add($creditNote->getTotal()); } return $balance; } }

Slide 79

Slide 79 text

Refining our invoicing workflow

Slide 80

Slide 80 text

framework: workflows: invoice: type: 'state_machine' supports: - Dayuse\Entity\Invoicing\Invoice marking_store: type: 'single_state' arguments: - 'status' initial_place: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_EMITTED places: - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_CREDITED - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_EMITTED - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_PAYMENT_DISPUTED - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_PAID transitions: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_TRANSITION_DECLARE_CREDITED: from: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_EMITTED to: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_CREDITED !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_TRANSITION_DECLARE_PAYMENT_DISPUTED: from: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_EMITTED to: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_PAYMENT_DISPUTED !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_TRANSITION_DECLARE_PAID: from: - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_PAYMENT_DISPUTED - !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_EMITTED to: !php/const Dayuse\Entity\Invoicing\Invoice::BILLING_STATUS_PAID

Slide 81

Slide 81 text

/** * @Route("/invoicing/item", name="invoicing_item_") * @IsGranted({"ROLE_TESTER", "ROLE_ADMIN"}) */ final class InvoiceItemController extends Controller { // ... /** * @Route("/declare-no-show/{id}/{bookingItemId}", name="declare_no_show") */ public function declareNoShow(Hotel $hotel, int $bookingItemId): Response { $this->init($hotel, $bookingItemId); $this->workflow->apply($this->item, InvoiceItem::TRANSITION_DECLARE_NO_SHOW); $this->objectManager->persist($this->item); $this->objectManager->flush(); return $this->redirectToRoute('invoicing_item_list_by_hotel', [ 'id' => $this->item->getHotel()->getId(), ]); } }

Slide 82

Slide 82 text

No content

Slide 83

Slide 83 text

No content

Slide 84

Slide 84 text

Being more Agile and Keep Delivering Value 5

Slide 85

Slide 85 text

Gitflow https://blog.xebia.fr/2018/03/28/gitflow-est-il-le-workflow-dont-jai-besoin/

Slide 86

Slide 86 text

Semver

Slide 87

Slide 87 text

No content

Slide 88

Slide 88 text

Encouraging our colleagues to use Gitlab

Slide 89

Slide 89 text

Leveraging Gitlab Features

Slide 90

Slide 90 text

« Agile » / « Project Management » Process

Slide 91

Slide 91 text

SymfonyLive 2019 / Sept. 26th / Berlin / Germany Hugo Hamon Thank you for listening!