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 Slide

  2. Hugo Hamon

    View Slide

  3. https://speakerdeck.com/hhamon

    View Slide

  4. Introduction
    0

    View Slide

  5. View Slide

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

    View Slide

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

    View Slide

  8. 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 Slide

  9. Securing the
    Technical Stack
    1

    View Slide

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

    View Slide

  11. Exceptions Logging with Sentry.io

    View Slide

  12. 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 Slide

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

    View Slide

  14. 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 Slide

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

    View Slide

  16. 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 Slide

  17. 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 Slide

  18. Custom
    Collections

    View Slide

  19. 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 Slide

  20. 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 Slide

  21. 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 Slide

  22. Gitlab CI + Quality Check Pipeline

    View Slide

  23. 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 Slide

  24. Monitoring HTTP requests with redirection.io

    View Slide

  25. Developer Experience

    View Slide

  26. Improving the
    User Experience
    2

    View Slide

  27. Rebuilding the
    Search Engine

    View Slide

  28. View Slide

  29. View Slide

  30. View Slide

  31. Rebuilding the
    Booking
    Funnel

    View Slide

  32. ?

    View Slide

  33. View Slide

  34. View Slide

  35. View Slide

  36. View Slide

  37. View Slide

  38. View Slide

  39. View Slide

  40. 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 Slide

  41. 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 Slide

  42. Reservations with a filled ETA
    June 20th 2019

    View Slide

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

    View Slide

  44. Transactional
    Emails

    View Slide

  45. View Slide

  46. View Slide

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

  48. I18N & L10N

    View Slide

  49. 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 Slide

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

  51. 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 Slide

  52. 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 Slide

  53. +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+
    | 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 Slide

  54. 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 Slide

  55. +-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+
    | 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 Slide

  56. Loco - https://localise.biz

    View Slide

  57. Manage translations projects

    View Slide

  58. View Slide

  59. Image
    Management

    View Slide

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

    View Slide

  61. # 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 Slide

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

    View Slide

  63. Aggregating
    Business Data
    3

    View Slide

  64. 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 Slide

  65. View Slide

  66. Tracking booking lifecycle
    Automatic reminders
    Lifecycle graph

    View Slide

  67. Tracking booking lifecycle

    View Slide

  68. 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 Slide

  69. 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 Slide

  70. 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 Slide

  71. 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 Slide

  72. 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 Slide

  73. Make Billing
    Great Again!
    4

    View Slide

  74. 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 Slide

  75. Goals
    •Extensible
    •Flexible
    •Automated
    •Reliable

    View Slide

  76. 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 Slide

  77. 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 Slide

  78. 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 Slide

  79. Refining our invoicing workflow

    View Slide

  80. 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 Slide

  81. /**
    * @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 Slide

  82. View Slide

  83. View Slide

  84. Being more Agile
    and Keep Delivering
    Value
    5

    View Slide

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

    View Slide

  86. Semver

    View Slide

  87. View Slide

  88. Encouraging our colleagues to use Gitlab

    View Slide

  89. Leveraging Gitlab Features

    View Slide

  90. « Agile » / « Project Management » Process

    View Slide

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

    View Slide