Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Dayuse.com - Challenges to Scale an Innovative Worldwide Business

Hugo Hamon
September 26, 2019

Dayuse.com - Challenges to Scale an Innovative Worldwide Business

Dayuse.com is an international market place helping hoteliers to sell their empty room and spaces during the day to maximize their revenues. Founded 9 years ago by David Lebée when he was managing a boutique hotel in Paris, Dayuse.com now operates in more than 20 countries in the world with more than 5,000 partner hotels. In order to scale the business and remaining the main leader on this market, Dayuse.com is continuously facing technical challenges to keep innovating and opening new markets. This talk will be a return of experience of the many challenges we face and how the tech team help other departments (sales, marketing, customer support, etc.) building their tools.

Hugo Hamon

September 26, 2019
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Dayuse.com 

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

    View full-size slide

  2. https://speakerdeck.com/hhamon

    View full-size slide

  3. Introduction
    0

    View full-size slide

  4. 70 people based
    in Paris, USA &
    Hong-Kong

    View full-size slide

  5. +5 000 hotels
    +1M bookings
    25 countries

    View full-size slide

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

    View full-size slide

  7. Securing the
    Technical Stack
    1

    View full-size slide

  8. PHP 5.6 -> PHP 7.2
    Symfony 2.6 -> 3.4

    View full-size slide

  9. Exceptions Logging with Sentry.io

    View full-size slide

  10. 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'
    # ...

    View full-size slide

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

    View full-size slide

  12. 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);
    }
    }

    View full-size slide

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

    View full-size slide

  14. 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;
    }
    }

    View full-size slide

  15. 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()
    ;
    }
    }

    View full-size slide

  16. Custom
    Collections

    View full-size slide

  17. 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;
    }
    }

    View full-size slide

  18. 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;
    }
    // ...
    }

    View full-size slide

  19. 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;
    }
    // ...
    }
    // ...
    }
    }

    View full-size slide

  20. Gitlab CI + Quality Check Pipeline

    View full-size slide

  21. 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"]');
    // ...
    }
    }

    View full-size slide

  22. Monitoring HTTP requests with redirection.io

    View full-size slide

  23. Developer Experience

    View full-size slide

  24. Improving the
    User Experience
    2

    View full-size slide

  25. Rebuilding the
    Search Engine

    View full-size slide

  26. Rebuilding the
    Booking
    Funnel

    View full-size slide

  27. 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

    View full-size slide

  28. 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),
    ]))
    );
    }
    }

    View full-size slide

  29. Reservations with a filled ETA
    June 20th 2019

    View full-size slide

  30. Reservations made with a registered account
    June 20th 2019

    View full-size slide

  31. Transactional
    Emails

    View full-size slide

  32. # 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%'

    View full-size slide

  33. 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

    View full-size slide

  34. 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();
    }
    }

    View full-size slide

  35. 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();
    }
    }

    View full-size slide

  36. 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;

    View full-size slide

  37. +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+
    | 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 |
    +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+

    View full-size slide

  38. 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;

    View full-size slide

  39. +-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+
    | 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)

    View full-size slide

  40. Loco - https://localise.biz

    View full-size slide

  41. Manage translations projects

    View full-size slide

  42. Image
    Management

    View full-size slide

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

    View full-size slide

  44. # 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'

    View full-size slide

  45. class="responsive-img swiper-lazy"
    alt="{{ hotelName }}"/>
    class="responsive-img swiper-lazy" alt="Le General
    Hôtel Paris 11ème"/>

    View full-size slide

  46. Aggregating
    Business Data
    3

    View full-size slide

  47. 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

    View full-size slide

  48. Tracking booking lifecycle
    Automatic reminders
    Lifecycle graph

    View full-size slide

  49. Tracking booking lifecycle

    View full-size slide

  50. 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

    View full-size slide

  51. 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);
    }
    }

    View full-size slide

  52. 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);
    }
    // ...
    }
    // ...
    }
    }

    View full-size slide

  53. 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());
    }
    }

    View full-size slide

  54. 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()
    ));
    }
    }

    View full-size slide

  55. Make Billing
    Great Again!
    4

    View full-size slide

  56. 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

    View full-size slide

  57. Goals
    •Extensible
    •Flexible
    •Automated
    •Reliable

    View full-size slide

  58. 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

    View full-size slide

  59. 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;
    }
    }

    View full-size slide

  60. 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;
    }
    }

    View full-size slide

  61. Refining our invoicing workflow

    View full-size slide

  62. 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

    View full-size slide

  63. /**
    * @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(),
    ]);
    }
    }

    View full-size slide

  64. Being more Agile
    and Keep Delivering
    Value
    5

    View full-size slide

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

    View full-size slide

  66. Encouraging our colleagues to use Gitlab

    View full-size slide

  67. Leveraging Gitlab Features

    View full-size slide

  68. « Agile » / « Project Management » Process

    View full-size slide

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

    View full-size slide