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

Dayuse.com - Challenges to Scale an Innovative ...

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. Main Challenges of 2018/2019 •Securing the technical stack •Improving the

    user experience •Aggregating more business data •Making billing more reliable •Becoming more agile!
  2. 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' # ...
  3. // ... use Dayuse\Service\ContentCachePool; class CachedFooterRenderer implements FooterBlockRenderer { //

    ... public function __construct( FooterBlockRenderer $renderer, LanguageRepository $languageRepository, ContentCachePool $cache, int $ttl = self::DEFAULT_TTL ) { // ... } }
  4. 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); } }
  5. 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; } }
  6. 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() ; } }
  7. 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; } }
  8. 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; } // ... }
  9. 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; } // ... } // ... } }
  10. 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"]'); // ... } }
  11. ?

  12. 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
  13. 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), ])) ); } }
  14. # 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%'
  15. 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
  16. 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(); } }
  17. 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(); } }
  18. 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;
  19. +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+ | 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 | +-------+-----------------------------------------------------------+---------------------+---------------------+---------------------+
  20. 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;
  21. +-------+----------------------------------------------------------------------+--------------+---------------------+---------------------+ | 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)
  22. • Free and Open-Source • Crop, resize & filter images

    • Caches and delivers images • WebP support • Symfony bundle integration
  23. # 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'
  24. <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"/>
  25. 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
  26. 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
  27. 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); } }
  28. 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); } // ... } // ... } }
  29. 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()); } }
  30. 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() )); } }
  31. 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
  32. 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
  33. 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; } }
  34. 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; } }
  35. 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
  36. /** * @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(), ]); } }