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

Dayuse.com - Challenges to Scale an Innovative Worldwide Business

E2ed7c278c8c49bb3e7fe0b7de039997?s=47 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.

E2ed7c278c8c49bb3e7fe0b7de039997?s=128

Hugo Hamon

September 26, 2019
Tweet

Transcript

  1. Dayuse.com 
 Challenges to scale an innovative worldwide business SymfonyLive

    2019 / Sept. 26th / Berlin / Germany
  2. Hugo Hamon

  3. https://speakerdeck.com/hhamon

  4. Introduction 0

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

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

  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!
  9. Securing the Technical Stack 1

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

  11. Exceptions Logging with Sentry.io

  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' # ...
  13. // ... use Dayuse\Service\ContentCachePool; class CachedFooterRenderer implements FooterBlockRenderer { //

    ... public function __construct( FooterBlockRenderer $renderer, LanguageRepository $languageRepository, ContentCachePool $cache, int $ttl = self::DEFAULT_TTL ) { // ... } }
  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); } }
  15. •Country •DateRange •DateTimeRange •Email •Money •PriceRange •Time •TimeSlot •etc. Value

    Objects
  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; } }
  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() ; } }
  18. Custom Collections

  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; } }
  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; } // ... }
  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; } // ... } // ... } }
  22. Gitlab CI + Quality Check Pipeline

  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"]'); // ... } }
  24. Monitoring HTTP requests with redirection.io

  25. Developer Experience

  26. Improving the User Experience 2

  27. Rebuilding the Search Engine

  28. None
  29. None
  30. None
  31. Rebuilding the Booking Funnel

  32. ?

  33. None
  34. None
  35. None
  36. None
  37. None
  38. None
  39. None
  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
  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), ])) ); } }
  42. Reservations with a filled ETA June 20th 2019

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

  44. Transactional Emails

  45. None
  46. None
  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%'
  48. I18N & L10N

  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
  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(); } }
  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(); } }
  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;
  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 | +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+
  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;
  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)
  56. Loco - https://localise.biz

  57. Manage translations projects

  58. None
  59. Image Management

  60. • Free and Open-Source • Crop, resize & filter images

    • Caches and delivers images • WebP support • Symfony bundle integration
  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'
  62. <img data-src="{{ image_url(hotelPhoto.original, 'big') }}" class="responsive-img swiper-lazy" alt="{{ hotelName }}"/>

    <img data-src="https://images.dayuse.com/ X9sgPqxKW_j6G4HdArkm9VcP09A=/797x533/hotels/0-3289/ ccc03d497d2a8d2c178ff9aef4966184-le-general-hotel.jpg" class="responsive-img swiper-lazy" alt="Le General Hôtel Paris 11ème"/>
  63. Aggregating Business Data 3

  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
  65. None
  66. Tracking booking lifecycle Automatic reminders Lifecycle graph

  67. Tracking booking lifecycle

  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
  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); } }
  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); } // ... } // ... } }
  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()); } }
  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() )); } }
  73. Make Billing Great Again! 4

  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
  75. Goals •Extensible •Flexible •Automated •Reliable

  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
  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; } }
  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; } }
  79. Refining our invoicing workflow

  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
  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(), ]); } }
  82. None
  83. None
  84. Being more Agile and Keep Delivering Value 5

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

  86. Semver

  87. None
  88. Encouraging our colleagues to use Gitlab

  89. Leveraging Gitlab Features

  90. « Agile » / « Project Management » Process

  91. SymfonyLive 2019 / Sept. 26th / Berlin / Germany Hugo

    Hamon Thank you for listening!