def generate_client_prices_for_stylist_services( stylist: Stylist, services: List[StylistService], client: Optional[Client], exclude_fully_booked: bool = False, exclude_unavailable_days: bool = False) -> List[ClientPriceOnDate]: prices_and_dates: Iterable[ PriceOnDate] = generate_prices_for_stylist_service( services, client, exclude_fully_booked, exclude_unavailable_days) client_prices_on_dates: List[ClientPriceOnDate] = [] for obj in prices_and_dates: availability_on_day = stylist.available_days.filter( weekday=obj.date.isoweekday(), is_available=True).last( ) if obj.date == stylist.get_current_now().date() else None stylist_eod = stylist.salon.timezone.localize( datetime.datetime.combine(date=obj.date, time=availability_on_day.work_end_at) ) if availability_on_day else None if not stylist_eod or stylist.get_current_now() < ( stylist_eod - stylist.service_time_gap - datetime.timedelta(minutes=END_OF_DAY_BUFFER_TIME_IN_MINUTES)): client_prices_on_dates.append( ClientPriceOnDate( date=obj.date, price=int( Decimal(obj.calculated_price.price).quantize( 0, ROUND_HALF_UP)), is_fully_booked=obj.is_fully_booked, is_working_day=obj.is_working_day, discount_type=obj.calculated_price.applied_discount, )) return client_prices_on_dates
def test_get_date_range_discount_percent(self, stylist_data: Stylist): assert (stylist_data.get_date_range_discount_percent( datetime.date(2018, 4, 9)) == 30) assert (stylist_data.get_date_range_discount_percent( datetime.date(2018, 4, 10)) == 30) assert (stylist_data.get_date_range_discount_percent( datetime.date(2018, 4, 12)) == 0)
def has_bookable_slots_with_discounts(stylist: Stylist, max_dates_to_look: int = 7): """ Return True if stylist is bookable and has at least one available slot with non-zero discounts in the next `max_dates_to_look` days (excluding today) :param stylist: Stylist to check :param max_dates_to_look: for how many days in the future to look :return: True if has bookable discounted slots, False otherwise """ today = stylist.with_salon_tz(timezone.now()).date() has_available_slots_with_discounts = False day_count = 0 # go over next 7 days, and find first available slot on a day for which stylist # has non-zero discount while not has_available_slots_with_discounts and day_count <= max_dates_to_look: day_count += 1 date_to_verify = today + datetime.timedelta(days=day_count) available_slots = list( filter(lambda a: not a.is_booked, stylist.get_available_slots(date=date_to_verify))) available_slot_count = len(available_slots) if available_slot_count: has_discount_on_this_day = stylist.get_weekday_discount_percent( Weekday(date_to_verify.isoweekday())) > 0 if has_discount_on_this_day: has_available_slots_with_discounts = True return has_available_slots_with_discounts
def create_stripe_account_for_stylist(stylist: Stylist, auth_code: str): """Create connected account for stylist in stripe, and save data to DB""" (account_id, stripe_access_token, stripe_refresh_token ) = create_connected_account_from_client_token(auth_code) stylist.stripe_account_id = account_id stylist.stripe_access_token = stripe_access_token stylist.stripe_refresh_token = stripe_refresh_token stylist.save(update_fields=[ 'stripe_account_id', 'stripe_access_token', 'stripe_refresh_token' ])
def get_next_deal_of_week_date(stylist: Stylist) -> Optional[datetime.date]: """Return date of next deal of the week if set by stylist, else None""" deal_of_week_weekday: Optional[int] = stylist.get_deal_of_week_weekday() if not deal_of_week_weekday: return None today = stylist.with_salon_tz(timezone.now()).date() today_weekday = today.isoweekday() if today_weekday < deal_of_week_weekday: return today + datetime.timedelta(deal_of_week_weekday - today_weekday) return today + datetime.timedelta(deal_of_week_weekday - today_weekday + 7)
def get_weekdays(self, stylist: Stylist): weekday_availability = [ stylist.get_or_create_weekday_availability(Weekday(weekday)) for weekday in range(1, 8) ] return StylistAvailableWeekDaySerializer(weekday_availability, many=True).data
def test_get_today_appointments( self, stylist_data: Stylist, ): appointments: Dict[str, Appointment] = stylist_appointments_data( stylist_data) today_appointments = [ a.id for a in stylist_data.get_today_appointments( upcoming_only=True, exclude_statuses=[ AppointmentStatus.CANCELLED_BY_CLIENT, AppointmentStatus.CANCELLED_BY_STYLIST, ]) ] assert (len(today_appointments) == 3) assert (appointments['current_appointment'].id in today_appointments) assert (appointments['future_appointment'].id in today_appointments) assert (appointments['late_night_appointment'].id in today_appointments) assert (appointments['next_day_appointment'].id not in today_appointments) assert (appointments['past_appointment'].id not in today_appointments) today_appointments = [ a.id for a in stylist_data.get_today_appointments( upcoming_only=False, exclude_statuses=[ AppointmentStatus.CANCELLED_BY_CLIENT, AppointmentStatus.CANCELLED_BY_STYLIST ]) ] assert (len(today_appointments) == 4) assert (appointments['current_appointment'].id in today_appointments) assert (appointments['past_appointment'].id in today_appointments) assert (appointments['future_appointment'].id in today_appointments) assert (appointments['late_night_appointment'].id in today_appointments) assert (appointments['next_day_appointment'].id not in today_appointments)
def test_set_status(self, stylist_data: Stylist): appointment: Appointment = G(Appointment, stylist=stylist_data, duration=datetime.timedelta()) assert (appointment.status == AppointmentStatus.NEW) appointment.set_status(AppointmentStatus.CANCELLED_BY_CLIENT, stylist_data.user) appointment.refresh_from_db() assert (appointment.status == AppointmentStatus.CANCELLED_BY_CLIENT) assert (appointment.status_history.latest('updated_at').updated_by == stylist_data.user) assert (appointment.status_history.latest('updated_at').updated_at == stylist_data.get_current_now())
def test_get_appointments_in_datetime_range( self, stylist_data: Stylist, ): appointments: Dict[str, Appointment] = stylist_appointments_data( stylist_data) all_appointments = stylist_data.get_appointments_in_datetime_range() assert (all_appointments.count() == 7) appointments_from_start = stylist_data.get_appointments_in_datetime_range( datetime_from=None, datetime_to=stylist_data.get_current_now(), including_to=True, exclude_statuses=[ AppointmentStatus.CANCELLED_BY_CLIENT, AppointmentStatus.CANCELLED_BY_STYLIST ]) assert (frozenset([a.id for a in appointments_from_start]) == frozenset([ appointments['past_appointment'].id, appointments['current_appointment'].id, appointments['last_week_appointment'].id, ])) apppointmens_to_end = stylist_data.get_appointments_in_datetime_range( datetime_from=stylist_data.get_current_now(), datetime_to=None, exclude_statuses=[ AppointmentStatus.CANCELLED_BY_CLIENT, AppointmentStatus.CANCELLED_BY_STYLIST ]) assert (frozenset([a.id for a in apppointmens_to_end]) == frozenset([ appointments['current_appointment'].id, appointments['future_appointment'].id, appointments['late_night_appointment'].id, appointments['next_day_appointment'].id, appointments['next_week_appointment'].id, ])) appointments_between = stylist_data.get_appointments_in_datetime_range( datetime_from=pytz.timezone('UTC').localize( datetime.datetime(2018, 5, 13)), datetime_to=pytz.timezone('UTC').localize( datetime.datetime(2018, 5, 15, 23, 59, 59)), exclude_statuses=[ AppointmentStatus.CANCELLED_BY_CLIENT, AppointmentStatus.CANCELLED_BY_STYLIST ]) assert (frozenset([a.id for a in appointments_between]) == frozenset([ appointments['past_appointment'].id, appointments['current_appointment'].id, appointments['future_appointment'].id, appointments['late_night_appointment'].id, appointments['next_day_appointment'].id, ]))
def get_date_with_lowest_price_on_current_week( stylist: Stylist, prices_on_dates: List[ClientPriceOnDate]) -> Optional[datetime.date]: """ Find the lowest, non-repeating price on this week and return it if it's today or is in the future. Return None otherwise. Non-repeating means that we need indeed lowest price, e.g. 1.0 out of [1.0, 2.0, 3.0] If there's repeating low price, e.g. [1.0, 2.0, 1.0, 3.0] - then we will return None """ # filter out prices of the remainder of this week only current_date = stylist.with_salon_tz(timezone.now()).date() weekday = current_date.isoweekday( ) % 7 # Sunday is the beginning of the week end_of_week_date = current_date + datetime.timedelta(days=6 - weekday) this_week_prices: List[ClientPriceOnDate] = list( filter( lambda price_on_date: current_date <= price_on_date.date <= end_of_week_date, prices_on_dates)) # There may be a situation when we don't have enough data for this week; return None in # this case if len(this_week_prices) < 2: return None # we need to find the ultimate, non-repeating minimum price. Let's sort this week's # prices; the first element will be the minimum, but we must check if the second element # is equal to it price-wise (which would mean that there's no single lowest price) prices_on_days_sorted = sorted( this_week_prices, key=lambda price_on_date: price_on_date.price) if prices_on_days_sorted[0].price == prices_on_days_sorted[1].price: # there's no local minimum: there are at least 2 days with equally low price return None if prices_on_days_sorted[0].date < current_date: # the lowest price is already in the past return None return prices_on_days_sorted[0].date
def get_has_picture_set(self, stylist: Stylist) -> bool: return stylist.get_profile_photo_url() is not None
def get_has_personal_data(self, stylist: Stylist) -> bool: full_name = stylist.get_full_name() has_name = full_name and len(full_name) > 1 salon: Optional[Salon] = getattr(stylist, 'salon') has_address = salon and salon.address return bool(has_name and stylist.user.phone and has_address)
def test_get_today_appointments(self, stylist_data: Stylist): client = G(Client) current_appointment = G(Appointment, client=client, stylist=stylist_data, datetime_start_at=pytz.timezone( stylist_data.salon.timezone).localize( datetime.datetime(2018, 5, 14, 13, 20)), duration=datetime.timedelta(minutes=30)) past_appointment = G(Appointment, client=client, stylist=stylist_data, datetime_start_at=pytz.timezone( stylist_data.salon.timezone).localize( datetime.datetime(2018, 5, 14, 12, 20)), duration=datetime.timedelta(minutes=30)) future_appointment = G(Appointment, client=client, stylist=stylist_data, datetime_start_at=pytz.timezone( stylist_data.salon.timezone).localize( datetime.datetime(2018, 5, 14, 14, 20)), duration=datetime.timedelta(minutes=30)) late_night_appointment = G(Appointment, client=client, stylist=stylist_data, datetime_start_at=pytz.timezone( stylist_data.salon.timezone).localize( datetime.datetime( 2018, 5, 14, 23, 50)), duration=datetime.timedelta(minutes=30)) next_day_appointment = G(Appointment, client=client, stylist=stylist_data, datetime_start_at=pytz.timezone( stylist_data.salon.timezone).localize( datetime.datetime( 2018, 5, 15, 13, 20)), duration=datetime.timedelta(minutes=30)) today_appointments = [ a.id for a in stylist_data.get_today_appointments() ] assert (len(today_appointments) == 3) assert (current_appointment.id in today_appointments) assert (past_appointment.id not in today_appointments) assert (future_appointment.id in today_appointments) assert (late_night_appointment.id in today_appointments) assert (next_day_appointment.id not in today_appointments) today_appointments = [ a.id for a in stylist_data.get_today_appointments(upcoming_only=False) ] assert (len(today_appointments) == 4) assert (current_appointment.id in today_appointments) assert (past_appointment.id in today_appointments) assert (future_appointment.id in today_appointments) assert (late_night_appointment.id in today_appointments) assert (next_day_appointment.id not in today_appointments)
def test_get_weekday_discount_percent(self, stylist_data: Stylist): assert (stylist_data.get_weekday_discount_percent( Weekday.MONDAY) == 50) assert (stylist_data.get_weekday_discount_percent( Weekday.TUESDAY) == 0)
def test_search_stylists(self, client, stylist_data: Stylist): location = stylist_data.salon.location G(Salon, location=location) stylist_data_2 = G(Stylist) client_data = G(Client) results = SearchStylistView._search_stylists('', '', location=location, country='US', client_id=client_data.id) assert (len(results) == 1) results = SearchStylistView._search_stylists('Fred', 'los altos', location=location, country='US', client_id=client_data.id) assert (len(results) == 1) assert (results[0] == stylist_data) assert (results[0].preference_uuid is None) preference = G(PreferredStylist, client=client_data, stylist=stylist_data) results = SearchStylistView._search_stylists('mcbob fr', 'rilma', location=location, country='US', client_id=client_data.id) assert (len(results) == 1) assert (results[0] == stylist_data) assert (results[0].preference_uuid == preference.uuid) results = SearchStylistView._search_stylists('mcbob fr', 'junk-address', location=location, country='US', client_id=client_data.id) assert (len(results) == 0) salon = stylist_data_2.salon salon.location = location salon.country = 'US' salon.save() results = SearchStylistView._search_stylists( stylist_data_2.get_full_name(), '', location=location, country='US', client_id=client_data.id) assert (len(results) == 1) assert (results[0] == stylist_data_2) results = SearchStylistView._search_stylists('some-junk-text', '', location=location, country='US', client_id=client_data.id) assert (len(results) == 0) # Test with deactivated stylist stylist_data.deactivated_at = timezone.now() stylist_data.save() results = SearchStylistView._search_stylists('Fred', 'los altos', location=location, country='US', client_id=client_data.id) assert (len(results) == 0)
def generate_client_pricing_hints( client: Client, stylist: Stylist, prices_on_dates: List[ClientPriceOnDate]) -> List[ClientPricingHint]: hints: List[ClientPricingHint] = [] MIN_DAYS_BEFORE_LOYALTY_DISCOUNT_HINT = 3 MIN_DAYS_BEFORE_DEAL_OF_WEEK = 2 loyalty_discount: LoyaltyDiscountTransitionInfo = get_current_loyalty_discount( stylist, client) current_discount_percent: int = loyalty_discount.current_discount_percent # 1. Check if current loyalty discount is about to transition to lower level if loyalty_discount.current_discount_percent and loyalty_discount.transitions_at: days_before_discount_ends = (loyalty_discount.transitions_at - timezone.now()).days if days_before_discount_ends <= MIN_DAYS_BEFORE_LOYALTY_DISCOUNT_HINT: if 0 < loyalty_discount.transitions_to_percent < current_discount_percent: # loyalty discount is about to transition to different non-zero # percentage, which is less than current discount hints.append( ClientPricingHint( priority=1, hint=('Your {current_discount}% loyalty discount ' 'reduces to {next_discount}% on {date}.').format( current_discount=loyalty_discount. current_discount_percent, next_discount=loyalty_discount. transitions_to_percent, date=loyalty_discount.transitions_at. strftime('%b, %-d')))) # loyalty discount just ends elif loyalty_discount.transitions_to_percent == 0: hints.append( ClientPricingHint( priority=1, hint= 'Book soon, your loyalty discount expires on {date}.'. format(date=loyalty_discount.transitions_at.strftime( '%b, %-d')))) # 2. Check if there's upcoming deal of week next_deal_of_week_date: Optional[ datetime.date] = get_next_deal_of_week_date(stylist=stylist) if next_deal_of_week_date: days_before_deal_of_week = ( next_deal_of_week_date - stylist.with_salon_tz(timezone.now()).date()).days if days_before_deal_of_week > MIN_DAYS_BEFORE_DEAL_OF_WEEK: hints.append( ClientPricingHint( priority=2, hint='Book the Deal of the Week this {weekday}.'.format( weekday=next_deal_of_week_date.strftime('%A')))) # 3. Check if there's a local minimum of price between current now and the # end of the week lowest_price_date = get_date_with_lowest_price_on_current_week( stylist=stylist, prices_on_dates=prices_on_dates) if lowest_price_date is not None: hints.append( ClientPricingHint(priority=3, hint='Best price this week is {weekday}.'.format( weekday=lowest_price_date.strftime('%A')))) # for now, we will only return the first element return hints[:1]
def get_upcoming_appointments(self, stylist: Stylist): return AppointmentSerializer(stylist.get_today_appointments(), many=True).data
def generate_demand_list_for_stylist( stylist: Stylist, dates: List[datetime.date]) -> List[DemandOnDate]: """ Generate list of NamedTuples with demand (0..1 float), is_fully_booked(boolean), is_working_day(boolean), for each date supplied. Demand is generated based on formula: appointment_count * service_time_gap / available_time_during_day. If resulting demand value is > 1, we set it to 1. If no time is available during a day (or stylist is just unavailable on particular date) demand value will also be equal to 1 """ weekday_available_times = get_weekday_available_times(stylist) time_gap = stylist.service_time_gap demand_list = [] for date_index, date in enumerate(dates): midnight = stylist.with_salon_tz( datetime.datetime.combine(date, datetime.time(0, 0))) next_midnight = midnight + datetime.timedelta(days=1) work_day_duration, stylist_weekday_availability = weekday_available_times[ date.isoweekday()] # if stylist has specifically marked date as unavailable - reflect it has_special_date_unavailable = stylist.special_available_dates.filter( date=date, is_available=False).exists() if has_special_date_unavailable: work_day_duration = datetime.timedelta(0) stylist_weekday_availability = None load_on_date_duration = stylist.appointments.filter( datetime_start_at__gte=midnight, datetime_start_at__lt=next_midnight, ).exclude(status__in=[ AppointmentStatus.CANCELLED_BY_STYLIST, AppointmentStatus.CANCELLED_BY_CLIENT ]).count() * time_gap is_stylist_weekday_available: bool = ( stylist_weekday_availability.is_available if stylist_weekday_availability else False) is_working_day: bool = is_stylist_weekday_available and not has_special_date_unavailable demand_on_date = (load_on_date_duration / work_day_duration if work_day_duration > datetime.timedelta(0) else COMPLETELY_BOOKED_DEMAND) # pricing experiment: add experimental pricing logic when discounts are gradually reduced # for dates which are closer to today: # Today: assume the demand is at least 75% even if it is lower # Tomorrow: assume the demand is at least 50% even if it is lower # The day after tomorrow: assume the demand is at least 25% even if it is lower # After that: use the real demand if date_index == 0: # today demand_on_date = max(demand_on_date, 0.75) elif date_index == 1: # tomorrow demand_on_date = max(demand_on_date, 0.5) elif date_index == 2: # day after tomorrow demand_on_date = max(demand_on_date, 0.25) # similar to demand_on_date which calculates the demand on the whole day, # we also need to calculate the demand during working hours to determine `is_fully_booked` if is_working_day: weekday_start_time = stylist.with_salon_tz( datetime.datetime.combine( date, stylist_weekday_availability.work_start_at)) weekday_end_time = stylist.with_salon_tz( datetime.datetime.combine( date, stylist_weekday_availability.work_end_at)) load_on_working_date_duration = stylist.appointments.filter( datetime_start_at__gte=weekday_start_time, datetime_start_at__lt=weekday_end_time, ).exclude(status__in=[ AppointmentStatus.CANCELLED_BY_STYLIST, AppointmentStatus.CANCELLED_BY_CLIENT ]).count() * time_gap else: load_on_working_date_duration = datetime.timedelta(seconds=0) demand_on_working_hours = (load_on_working_date_duration / work_day_duration if work_day_duration > datetime.timedelta(0) else COMPLETELY_BOOKED_DEMAND) is_fully_booked: bool = False # Technically, there may be a situation of overbooking, which may cause actual # demand be > 1; in this case we'll cast it to 1 manually if demand_on_date > 1: demand_on_date = 1 if is_working_day and demand_on_working_hours == COMPLETELY_BOOKED_DEMAND: is_fully_booked = True demand = DemandOnDate(demand=demand_on_date, is_fully_booked=is_fully_booked, is_working_day=is_working_day) demand_list.append(demand) return demand_list
def test_other_discounts_set(self, stylist_data: Stylist): assert ( StylistProfileStatusSerializer( instance=stylist_data).data['has_other_discounts_set'] is False ) stylist_data.first_time_book_discount_percent = 10 stylist_data.save() assert ( StylistProfileStatusSerializer( instance=stylist_data).data['has_other_discounts_set'] is True ) stylist_data.first_time_book_discount_percent = 0 stylist_data.rebook_within_1_week_discount_percent = 10 stylist_data.save() assert ( StylistProfileStatusSerializer( instance=stylist_data).data['has_other_discounts_set'] is True ) stylist_data.first_time_book_discount_percent = 0 stylist_data.rebook_within_1_week_discount_percent = 0 stylist_data.save() G( StylistDateRangeDiscount, stylist=stylist_data, dates=DateRange(datetime.date(2018, 4, 8), datetime.date(2018, 4, 10)) ) assert ( StylistProfileStatusSerializer( instance=stylist_data).data['has_other_discounts_set'] is True )
def build_appointment_preview_dict( stylist: Stylist, client: Optional[Client], preview_request: AppointmentPreviewRequest ) -> AppointmentPreviewResponse: service_items: List[AppointmentServicePreview] = [] appointment: Optional[Appointment] = None status = AppointmentStatus.NEW if preview_request.appointment_uuid is not None: appointment = get_object_or_404(stylist.appointments, uuid=preview_request.appointment_uuid) status = appointment.status total_discount_percentage: int = 0 if appointment: total_discount_percentage = appointment.total_discount_percentage for service_request_item in preview_request.services: appointment_service: Optional[AppointmentService] = None if appointment: appointment_service = appointment.services.filter( service_uuid=service_request_item['service_uuid']).last() service_client_price: Optional[Decimal] = service_request_item[ 'client_price'] if 'client_price' in service_request_item else None if appointment_service: if service_client_price: # client price (overriding current regular price) was provided. We will # temporarily (w/out committing to DB) replace regular price of the service # with client-supplied, and will re-calculate client_price (i.e. client-facing) # with existing appointment's discount appointment_service.set_client_price(service_client_price, commit=False) # service already exists in appointment, and will not be recalculated, # so we should take it's data verbatim service_item = AppointmentServicePreview( uuid=appointment_service.uuid, service_uuid=appointment_service.service_uuid, service_name=appointment_service.service_name, regular_price=appointment_service.regular_price, client_price=appointment_service.client_price, duration=appointment_service.duration, is_original=appointment_service.is_original) if not total_discount_percentage: total_discount_percentage = appointment_service.discount_percentage else: # appointment service doesn't exist in appointment yet, and is to be added, so we # need to calculate the price for it. service: StylistService = stylist.services.get( uuid=service_request_item['service_uuid']) # We need to decide what we use for the base price. If client_price is supplied # we will use it as a base price. Otherwise, we will take base price from stylist's # service if not service_client_price: regular_price = service.regular_price else: regular_price = service_client_price # now when we know the base price, we need to calculate price with discount. # if the appointment we're previewing is based on the existing appointment - # we will just take discount percentage from it. Otherwise, we need to run # pricing calculation for given client and stylist on the given date, and see # if there is any discount there if not appointment: calculated_price = calculate_price_and_discount_for_client_on_date( service=service, client=client, date=preview_request.datetime_start_at.date()) client_price = Decimal(calculated_price.price) if not total_discount_percentage: total_discount_percentage = calculated_price.discount_percentage else: client_price = calculate_price_with_discount_based_on_appointment( regular_price, appointment) service_item = AppointmentServicePreview( uuid=None, service_uuid=service.uuid, service_name=service.name, regular_price=service.regular_price, client_price=client_price, duration=service.duration, is_original=True if not appointment else False) service_items.append(service_item) total_client_price_before_tax = sum( [s.client_price for s in service_items], Decimal(0)) total_regular_price = sum([s.regular_price for s in service_items], Decimal(0)) appointment_prices: AppointmentPrices = calculate_appointment_prices( price_before_tax=total_client_price_before_tax, include_card_fee=preview_request.has_card_fee_included, include_tax=preview_request.has_tax_included, tax_rate=stylist.tax_rate, card_fee=stylist.card_fee) duration = stylist.service_time_gap conflicts_with = stylist.get_appointments_in_datetime_range( preview_request.datetime_start_at, preview_request.datetime_start_at, including_to=True, exclude_statuses=[ AppointmentStatus.CANCELLED_BY_STYLIST, AppointmentStatus.CANCELLED_BY_CLIENT ]) return AppointmentPreviewResponse( duration=duration, conflicts_with=conflicts_with, total_client_price_before_tax=appointment_prices. total_client_price_before_tax, grand_total=appointment_prices.grand_total, tax_percentage=float(stylist.tax_rate) * 100, card_fee_percentage=float(stylist.card_fee) * 100, total_tax=appointment_prices.total_tax, total_card_fee=appointment_prices.total_card_fee, has_tax_included=appointment_prices.has_tax_included, has_card_fee_included=appointment_prices.has_card_fee_included, services=service_items, stylist=stylist, datetime_start_at=preview_request.datetime_start_at, status=status, total_discount_percentage=total_discount_percentage, total_discount_amount=max( total_regular_price - total_client_price_before_tax, Decimal(0)))