Warning
You are browsing the documentation for the new Sharetribe Web Template. If you are using FTW-daily, hourly or product, see the legacy documentation.

Last updated

Customize pricing

Learn how to customize pricing in your marketplace by adding an optional cleaning fee on top of the regular nightly price of the accommodation.

Table of Contents

In this tutorial, you will

  • Allow providers to add a cleaning fee to their listings
  • Allow customers to select whether they want to include the cleaning fee in their booking
  • Include a selected cleaning fee in the transaction's pricing

Store cleaning fee into listing

Pricing can be based on a lot of variables, and one practical way to build it is to base it on information stored as extended data in listings. In this example, we are using a listing's public data to store information about the cleaning fee.

We will not add new fields to listing configuration in Flex Console, since we do not want to show the cleaning fee in the Details panel. Instead, we start by making some changes to EditListingPricingPanel in EditListingWizard.

└── src
    └── containers
        └── EditListingPage
            └── EditListingWizard
                └── EditListingPricingPanel
                    └── EditListingPricingPanel.js

Save to public data

In EditListingPricingPanel, we need to edit the onSubmit function to save the new public data field called cleaningFee. Because we are using FieldCurrencyInput component in this example as the input of choice, the cleaningFee variable will be a Money object when we get it from the submitted values. Money object can't be used directly as public data, so we need to create a JSON object with keys amount and currency, and use it in the underlying API call.

onSubmit={values => {
  const { price, cleaningFee = null } = values;

  const updatedValues = {
    price,
    publicData: {
      cleaningFee: { amount: cleaningFee.amount, currency: cleaningFee.currency },
    },
  };
  onSubmit(updatedValues);
}}

Initialize the form

Next, we want to pass inital values for price and cleaningFee. For this, we need to get the cleaningFee from listing attributes under the publicData key. Also, because FieldCurrencyInput expects the value to be a Money object, we need to convert the value we get from Marketplace API back to an instance of Money.

const getInitialValues = params => {
  const { listing } = params;
  const { price, publicData } = listing?.attributes || {};

  const cleaningFee = publicData?.cleaningFee || null;

  const cleaningFeeAsMoney = cleaningFee
    ? new Money(cleaningFee.amount, cleaningFee.currency)
    : null;

  return { price, cleaningFee: cleaningFeeAsMoney };
};

Now pass the whole initialValues map in the corresponding prop to EditListingPricingForm.

Add input component

We want to be able to save the listing's cleaning fee amount, so we add a new FieldCurrencyInput to the EditListingPricingForm. The id and name of this field will be cleaningFee.

Adding this fee will be optional, so we don't want to add any validate param to the FieldCurrencyInput like there is in the price input.

└── src
    └── containers
        └── EditListingPage
            └── EditListingWizard
                └── EditListingPricingPanel
                    └── EditListingPricingForm.js
...

<FieldCurrencyInput
  id={`${formId}price`}
  name="price"
  className={css.input}
  autoFocus={autoFocus}
  label={intl.formatMessage(
    { id: 'EditListingPricingForm.pricePerProduct' },
    { unitType }
  )}
  placeholder={intl.formatMessage({ id: 'EditListingPricingForm.priceInputPlaceholder' })}
  currencyConfig={appSettings.getCurrencyFormatting(marketplaceCurrency)}
  validate={priceValidators}
/>
<FieldCurrencyInput
  id={`${formId}cleaningFee`}
  name="cleaningFee"
  className={css.input}
  autoFocus={autoFocus}
  label={intl.formatMessage(
    { id: 'EditListingPricingForm.cleaningFee' },
    { unitType }
  )}
  placeholder={intl.formatMessage({ id: 'EditListingPricingForm.cleaningFeePlaceholder' })}
  currencyConfig={appSettings.getCurrencyFormatting(marketplaceCurrency)}
/>
...

You can use the following microcopy keys:

  "EditListingPricingForm.cleaningFee":"Cleaning fee (optional)",
  "EditListingPricingForm.cleaningFeePlaceholder": "Add a cleaning fee..."

After adding the new microcopy keys, the EditListingPricingPanel should look something like this: EditListingPricePanel

Update BookingDatesForm

In our example the cleaning fee is optional, and users can select it as an add-on to their booking. In this section, we will add the UI component for selecting the cleaning fee and pass the information about the user's choice to the the backend of our client app.

In case you want to add the cleaning fee automatically to every booking, you don't need to add the UI component for selecting the cleaning fee, and you can move forward to the next section: Add a new line item for the cleaning fee.

Prepare props

To use the information about cleaning fee inside the BookingDatesForm, we need to pass some new information from OrderPanel to the form. OrderPanel is the component used on ListingPage and TransactionPage to show the order breakdown.

└── src
    └── components
        └── OrderPanel
            └── OrderPanel.js

OrderPanel gets listing as a prop. The cleaning fee is now saved in the listing's public data, so we can find it under the publicData key in the listing's attributes.

Because adding a cleaning fee to a listing is optional, we need to check whether or not the cleaningFee exists in public data.

const cleaningFee = listing?.attributes?.publicData.cleaningFee;

Once we have saved the cleaning fee information to the variable cleaningFee, we need to pass it forward to BookingDatesForm. This form is used for collecting the order data (e.g. booking dates), and values from this form will be used when creating the transaction line items. We will pass the cleaningFee to this form as a new prop.

  <BookingDatesForm
    className={css.bookingForm}
    formId="OrderPanelBookingDatesForm"
    lineItemUnitType={lineItemUnitType}
    onSubmit={onSubmit}
    price={price}
    marketplaceCurrency={marketplaceCurrency}
    dayCountAvailableForBooking={dayCountAvailableForBooking}
    listingId={listing.id}
    isOwnListing={isOwnListing}
    monthlyTimeSlots={monthlyTimeSlots}
    onFetchTimeSlots={onFetchTimeSlots}
    timeZone={timeZone}
    marketplaceName={marketplaceName}
    onFetchTransactionLineItems={onFetchTransactionLineItems}
    lineItems={lineItems}
    fetchLineItemsInProgress={fetchLineItemsInProgress}
    fetchLineItemsError={fetchLineItemsError}
+   cleaningFee={cleaningFee}
  />

Add cleaning fee checkbox

Next, we need to add a new field to BookingDatesForm for selecting the possible cleaning fee. For this, we will use the FieldCheckbox component, because we want the cleaning fee to be optional.

└── src
    └── components
        └── OrderPanel
            └── BookingDatesForm
                └── BookingDatesForm.js
                └── BookingDatesForm.module.css

In BookingDatesForm we need to import a couple of new resources we need to add the cleaning fee. These will include a few helper functions necessary to handle the cleaningFee price information, as well as the checkbox component FieldCheckbox.

  import { propTypes } from '../../util/types';
+ import { formatMoney } from '../../../util/currency';
+ import { types as sdkTypes } from '../../../util/sdkLoader';
  ...
import {
  Form,
  IconArrowHead,
  PrimaryButton,
  FieldDateRangeInput,
  H6,
+ FieldCheckbox,
} from '../../../components';

 import EstimatedCustomerBreakdownMaybe from './EstimatedCustomerBreakdownMaybe';

 import css from './BookingDatesForm.module.css';
+ const { Money } = sdkTypes;

When we have imported these files, we will add the checkbox component for selecting the cleaning fee. For this, we need to extract the cleaningFee from fieldRenderProps.

    ...
    lineItems,
    fetchLineItemsError,
    onFetchTimeSlots,
+   cleaningFee,
  } = fieldRenderProps;

We want to show the amount of cleaning fee to the user in the checkbox label, so we need to format cleaningFee to a printable form. For this, we want to use the formatMoney function that uses localized formatting. This function expects a Money object as a parameter, so we need to do the conversion.

const formattedCleaningFee = cleaningFee
  ? formatMoney(
      intl,
      new Money(cleaningFee.amount, cleaningFee.currency)
    )
  : null;

const cleaningFeeLabel = intl.formatMessage(
  { id: 'BookingDatesForm.cleaningFeeLabel' },
  { fee: formattedCleaningFee }
);

We will also add a new microcopy key BookingDatesForm.cleaningFeeLabel to the en.json file, and we can use the fee variable to show the price.

  "BookingDatesForm.cleaningFeeLabel": "Cleaning fee: {fee}",

Because there might be listings without a cleaning fee, we want to show the checkbox only when needed. This is why we will create the cleaningFeeMaybe component which is rendered only if the listing has a cleaning fee saved in its public data.

const cleaningFeeMaybe = cleaningFee ? (
  <FieldCheckbox
    className={css.cleaningFeeContainer}
    id={`${formId}.cleaningFee`}
    name="cleaningFee"
    label={cleaningFeeLabel}
    value="cleaningFee"
  />
) : null;

Then we can add the cleaningFeeMaybe to the returned <Form> component

...
    isDayBlocked={isDayBlocked}
    isOutsideRange={isOutsideRange}
    isBlockedBetween={isBlockedBetween(monthlyTimeSlots, timeZone)}
    disabled={fetchLineItemsInProgress}
    onClose={event =>
      setCurrentMonth(getStartOf(event?.startDate ?? startOfToday, 'month', timeZone))
    }
  />

+ {cleaningFeeMaybe}

  {showEstimatedBreakdown ? (
    <div className={css.priceBreakdownContainer}>
      <h3>
...

As the final step for adding the checkbox, add the corresponding CSS class to BookingDatesForm.module.css.

.cleaningFeeContainer {
  margin-top: 24px;
}

After this step, the BookingDatesForm should look like this. Note that the cleaning fee will not be visible in the order breakdown yet, even though we added the new checkbox.

Cleaning fee checkbox

Update the orderData

Next, we want to pass the value of the cleaning fee checkbox as part of the orderData. This is needed so that we can show the selected cleaning fee as a new row in the order breakdown. To achieve this, we need to edit the handleOnChange function, which takes the values from the form and calls the onFetchTransactionLineItems function for constructing the transaction line items. These line items are then shown inside the bookingInfoMaybe component under the form fields.

In the orderData object, we have all the information about the user's choices. In this case, this includes booking dates, and whether or not they selected the cleaning fee.

We only need to know if the cleaning fee was selected. We will fetch the cleaning fee details from Marketplace API later in the the backend of our client app to make sure this information cannot be manipulated.

In our case, because there is just one checkbox, it's enough to check the length of that array to determine if any items are selected. If the length of the cleaningFee array inside values is bigger than 0, the hasCleaningFee param is true, and otherwise it is false. If we had more than one item in the checkbox group, we should check which items were selected.

const handleFormSpyChange = (
  listingId,
  isOwnListing,
  fetchLineItemsInProgress,
  onFetchTransactionLineItems
) => formValues => {
  const { startDate, endDate } =
    formValues.values && formValues.values.bookingDates
      ? formValues.values.bookingDates
      : {};

  const hasCleaningFee = formValues.values?.cleaningFee?.length > 0;

  if (startDate && endDate && !fetchLineItemsInProgress) {
    onFetchTransactionLineItems({
      orderData: {
        bookingStart: startDate,
        bookingEnd: endDate,
        hasCleaningFee,
      },
      listingId,
      isOwnListing,
    });
  }
};

Add a new line-item for the cleaning fee

We are making progress! Next, we need to edit the the backend of our client app, and add a new line item for the cleaning fee, so that it can be included in pricing.

Flex uses privileged transitions to ensure that the pricing logic is handled in a secure environment. This means that constructing line items and transitioning requests of privileged transitions are made server-side.

Since we want to add a new line item for the cleaning fee, we'll need to update the pricing logic in the lineItems.js file:

└── server
    └── api-util
        ├── lineItems.js
        └── lineItemHelpers.js

Resolve the cleaning fee

First, we will add a new helper function for resolving the cleaning fee line item. This function will take the listing as a parameter, and then get the cleaning fee from its public data. To make sure the data cannot be manipulated, we don't pass it directly from the template frontend. Instead, we fetch the listing from Marketplace API, and check that listing's public data for the accurate cleaning fee.

If you have several helper functions, you might want to add this function to the lineItemHelpers.js file instead.

const resolveCleaningFeePrice = listing => {
  const publicData = listing.attributes.publicData;
  const cleaningFee = publicData && publicData.cleaningFee;
  const { amount, currency } = cleaningFee;

  if (amount && currency) {
    return new Money(amount, currency);
  }

  return null;
};

Add line-item

Now the transactionLineItems function can be updated to also provide the cleaning fee line item when the listing has a cleaning fee.

In this example, the provider commission is calculated from the total of booking and cleaning fees. That's why we need to add the cleaningFee item also to calculateTotalFromLineItems(...) function in the providerCommission line item. If we don't add the cleaning fee, the provider commission calculation is only based on the booking fee.

Also remember to add the cleaning fee to the lineItems array that is returned in the end of the function.

exports.transactionLineItems = (listing, orderData) => {
...

  const order = {
    code,
    unitPrice,
    quantity,
    includeFor: ['customer', 'provider'],
  };

+ const cleaningFeePrice = orderData.hasCleaningFee ? resolveCleaningFeePrice(listing) : null;
+ const cleaningFee = cleaningFeePrice
+   ? [
+       {
+         code: 'line-item/cleaning-fee',
+         unitPrice: cleaningFeePrice,
+         quantity: 1,
+         includeFor: ['customer', 'provider'],
+       },
+     ]
+   : [];
+

  // Provider commission reduces the amount of money that is paid out to provider.
  // Therefore, the provider commission line-item should have negative effect to the payout total.
  const getNegation = percentage => {
    return -1 * percentage;
  };

  // Note: extraLineItems for product selling (aka shipping fee)
  //       is not included to commission calculation.
  const providerCommissionMaybe = hasCommissionPercentage(providerCommission)
    ? [
        {
          code: 'line-item/provider-commission',
-         unitPrice: calculateTotalFromLineItems([order]),
+         unitPrice: calculateTotalFromLineItems([order, ...cleaningFee]),
          percentage: getNegation(providerCommission.percentage),
          includeFor: ['provider'],
        },
      ]
    : [];

  // Let's keep the base price (order) as first line item and provider's commission as last one.
  // Note: the order matters only if OrderBreakdown component doesn't recognize line-item.
- const lineItems = [order, ...extraLineItems, ...providerCommissionMaybe];
+ const lineItems = [order, ...extraLineItems, ...cleaningFee, ...providerCommissionMaybe];


  return lineItems;
};

Once we have made the changes to the backend of our client app, we can check the order breakdown again. If you now choose the cleaning fee, you should see the cleaning fee in the booking breakdown:

Cleaning fee in booking breakdown

Update CheckoutPage to handle cleaning fee

Finally, we want to update the Checkout Page so that it takes the cleaning fee selection into account when the customer actually pays for the booking.

Fetch speculated transaction complete with cleaning fee

When a user clicks "Request to book", ListingPage.js sends the booking details as initial values to CheckoutPage.js, which then fetches the possible transaction information, including pricing, to be shown on the checkout page. In Flex language, this is known as "speculating" the transaction - the booking has not been made, but the line items are calculated as if it were.

This means that we need to first pass the cleaning fee information to the function that speculatively fetches the transaction in CheckoutPage.js, and then receive it in CheckoutPage.duck.js. First, the CheckoutPage.js loadInitialData() does some data processing and, if necessary, calls fetchSpeculatedTransaction().

└── src
    └── containers
        └── CheckoutPage
            └── CheckoutPage.js
loadInitialData() {
  ...
      const deliveryMethod = pageData.orderData?.deliveryMethod;
+     const hasCleaningFee = pageData.orderData?.cleaningFee?.length > 0;
      fetchSpeculatedTransaction(
        {
          listingId,
          deliveryMethod,
+         hasCleaningFee,
          ...quantityMaybe,
          ...bookingDatesMaybe(pageData.orderData.bookingDates),
        },
        processAlias,
        transactionId,
        requestTransition,
        isPrivileged
      );
...

This function call dispatches a speculateTransaction action in CheckoutPage.duck.js, which in turn calls the template server using the correct endpoint.

└── src
    └── containers
        └── CheckoutPage
            └── CheckoutPage.duck.js

To pass the cleaning fee selection to the API call, we add it to orderData within the speculateTransaction action.

export const speculateTransaction = (
  ...
- const { deliveryMethod, quantity, bookingDates, ...otherOrderParams } = orderParams;
+ const { deliveryMethod, quantity, bookingDates, hasCleaningFee, ...otherOrderParams } = orderParams;
...

  // Parameters only for client app's server
- const orderData = deliveryMethod ? { deliveryMethod } : {};
+ const orderData = deliveryMethod || hasCleaningFee ? { deliveryMethod, hasCleaningFee } : {};

Now when the customer selects cleaning fee on the listing page and clicks "Request to book", we see the correct price and breakdown on the checkout page.

Cleaning fee in booking breakdown on checkout page

Include cleaning fee in the final transaction price

The final step is to add the same logic to the flow that eventually sets the price for the transaction.

└── src
    └── containers
        └── CheckoutPage
            └── CheckoutPage.js

In CheckoutPage.js, the function that does the heavy lifting in handling the payment processing is handlePaymentIntent(). In short, it first creates five functions to handle the transaction payment process, then composes them into a single function handlePaymentIntentCreation(), and then calls that function with parameter orderParams.

To add the cleaning fee information into this process, we want to include it in orderParams, which is defined towards the very end of handlePaymentIntent() function.

    const deliveryMethod = pageData.orderData?.deliveryMethod;
+   const hasCleaningFee = pageData.orderData?.cleaningFee?.length > 0;
...
    const orderParams = {
      listingId: pageData.listing.id,
      deliveryMethod,
+     hasCleaningFee,
      ...quantityMaybe,
      ...bookingDatesMaybe(pageData.orderData.bookingDates),
      ...protectedDataMaybe,
      ...optionalPaymentParams,
    };

Then, we still need to add the cleaning fee information to the correct action in CheckoutPage.duck.js.

└── src
    └── containers
        └── CheckoutPage
            └── CheckoutPage.duck.js

The first function in the handlePaymentIntentCreation() composition is fnRequestPayment. It initiates the order if there is no existing paymentIntent, and in practice it dispatches the initiateOrder action that calls the template server. So similarly to the speculateTransaction action, we just need to add the cleaning fee selection to orderData in initiateOrder.

export const initiateOrder = (
  ...

- const { deliveryMethod, quantity, bookingDates, ...otherOrderParams } = orderParams;
+ const { deliveryMethod, quantity, bookingDates, hasCleaningFee, ...otherOrderParams } = orderParams;
...

  // Parameters only for client app's server
- const orderData = deliveryMethod ? { deliveryMethod } : {};
+ const orderData = deliveryMethod || hasCleaningFee ? { deliveryMethod, hasCleaningFee } : {};

Now you can try it out! You may have to refresh your application first, so that the Redux changes take effect. When you complete a booking on a listing that has a cleaning fee specified, you can see the cleaning fee included in the price on the booking page. In addition, the Flex Console transaction price breakdown also shows the cleaning fee.

Cleaning fee in booking breakdown in Flex Console

To add the cleaning fee into your email notifications, you will need to add it to the email templates. The third step of this tutorial deals with updating email notifications.
      {{#each tx-line-items}}
        {{#contains include-for "provider"}}
          {{#eq "line-item/day" code}} ...

+          {{#eq "line-item/cleaning-fee" code}}
+            <tr class="bottom-row">
+              <td>Cleaning fee</td>
+              <td class="right">{{> format-money money=line-total}}</td>
+            </tr>
+          {{/eq}}

          {{#eq "line-item/provider-commission" code}} ...
        {{/contains}}
      {{/each}}

The email templates that list the full line items in the default booking process are

  • new-booking-request (to provider)
  • booking-request-accepted (to customer)
  • money-paid (to provider)

Summary

In this tutorial, you have

  • Saved a cleaning fee attribute to the listing's public data in EditListingPricingPanel
  • Updated the BookingDatesForm and OrderPanel to show and handle cleaning fee selection
  • Added cleaning fee to line item handling server-side
  • Updated the CheckoutPage to include cleaning fee in the booking's pricing