From 9c3b7e5024ddff43a2d57285e52180ba95bb683f Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 9 Jun 2020 14:36:51 +0300 Subject: [PATCH 01/13] Use validation function when returning lineItems directly from FTW endpoint --- server/api/transaction-line-items.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/api/transaction-line-items.js b/server/api/transaction-line-items.js index fe3e1fb61c..f5ebd10778 100644 --- a/server/api/transaction-line-items.js +++ b/server/api/transaction-line-items.js @@ -1,5 +1,6 @@ const { transactionLineItems } = require('../api-util/lineItems'); const { getSdk, handleError, serialize } = require('../api-util/sdk'); +const { constructValidLineItems } = require('../api-util/lineItemHelpers'); module.exports = (req, res) => { const { isOwnListing, listingId, bookingData } = req.body; @@ -14,10 +15,15 @@ module.exports = (req, res) => { .then(apiResponse => { const listing = apiResponse.data.data; const lineItems = transactionLineItems(listing, bookingData); + + // Because we are using returned lineItems directly in FTW we need to use the helper function + // to add some attributes like lineTotal and reversal that Marketplace API also adds to the response. + const validLineItems = constructValidLineItems(lineItems); + res .status(200) .set('Content-Type', 'application/transit+json') - .send(serialize({ data: lineItems })) + .send(serialize({ data: validLineItems })) .end(); }) .catch(e => { From 6206e1e4c93b580ffa1f6b3aed46e74296636061 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 9 Jun 2020 14:37:32 +0300 Subject: [PATCH 02/13] Add fetchTransactionLineItems function to ListingPage.duck.js --- .../ListingPage/ListingPage.duck.js | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/containers/ListingPage/ListingPage.duck.js b/src/containers/ListingPage/ListingPage.duck.js index 5c6887ab55..8f856d181a 100644 --- a/src/containers/ListingPage/ListingPage.duck.js +++ b/src/containers/ListingPage/ListingPage.duck.js @@ -4,6 +4,7 @@ import config from '../../config'; import { types as sdkTypes } from '../../util/sdkLoader'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; +import { transactionLineItems } from '../../util/api'; import { denormalisedResponseEntities } from '../../util/data'; import { TRANSITION_ENQUIRE } from '../../util/transaction'; import { @@ -29,6 +30,10 @@ export const FETCH_TIME_SLOTS_REQUEST = 'app/ListingPage/FETCH_TIME_SLOTS_REQUES export const FETCH_TIME_SLOTS_SUCCESS = 'app/ListingPage/FETCH_TIME_SLOTS_SUCCESS'; export const FETCH_TIME_SLOTS_ERROR = 'app/ListingPage/FETCH_TIME_SLOTS_ERROR'; +export const FETCH_LINE_ITEMS_REQUEST = 'app/ListingPage/FETCH_LINE_ITEMS_REQUEST'; +export const FETCH_LINE_ITEMS_SUCCESS = 'app/ListingPage/FETCH_LINE_ITEMS_SUCCESS'; +export const FETCH_LINE_ITEMS_ERROR = 'app/ListingPage/FETCH_LINE_ITEMS_ERROR'; + export const SEND_ENQUIRY_REQUEST = 'app/ListingPage/SEND_ENQUIRY_REQUEST'; export const SEND_ENQUIRY_SUCCESS = 'app/ListingPage/SEND_ENQUIRY_SUCCESS'; export const SEND_ENQUIRY_ERROR = 'app/ListingPage/SEND_ENQUIRY_ERROR'; @@ -42,6 +47,9 @@ const initialState = { fetchReviewsError: null, timeSlots: null, fetchTimeSlotsError: null, + lineItems: null, + fetchLineItemsInProgress: false, + fetchLineItemsError: null, sendEnquiryInProgress: false, sendEnquiryError: null, enquiryModalOpenForListingId: null, @@ -72,6 +80,13 @@ const listingPageReducer = (state = initialState, action = {}) => { case FETCH_TIME_SLOTS_ERROR: return { ...state, fetchTimeSlotsError: payload }; + case FETCH_LINE_ITEMS_REQUEST: + return { ...state, fetchLineItemsInProgress: true, fetchLineItemsError: null }; + case FETCH_LINE_ITEMS_SUCCESS: + return { ...state, fetchLineItemsInProgress: false, lineItems: payload }; + case FETCH_LINE_ITEMS_ERROR: + return { ...state, fetchLineItemsInProgress: false, fetchLineItemsError: payload }; + case SEND_ENQUIRY_REQUEST: return { ...state, sendEnquiryInProgress: true, sendEnquiryError: null }; case SEND_ENQUIRY_SUCCESS: @@ -123,6 +138,17 @@ export const fetchTimeSlotsError = error => ({ payload: error, }); +export const fetchLineItemsRequest = () => ({ type: FETCH_LINE_ITEMS_REQUEST }); +export const fetchLineItemsSuccess = lineItems => ({ + type: FETCH_LINE_ITEMS_SUCCESS, + payload: lineItems, +}); +export const fetchLineItemsError = error => ({ + type: FETCH_LINE_ITEMS_ERROR, + error: true, + payload: error, +}); + export const sendEnquiryRequest = () => ({ type: SEND_ENQUIRY_REQUEST }); export const sendEnquirySuccess = () => ({ type: SEND_ENQUIRY_SUCCESS }); export const sendEnquiryError = e => ({ type: SEND_ENQUIRY_ERROR, error: true, payload: e }); @@ -269,6 +295,20 @@ export const sendEnquiry = (listingId, message) => (dispatch, getState, sdk) => }); }; +export const fetchTransactionLineItems = ({ bookingData, listingId, isOwnListing }) => dispatch => { + console.log('On fetch line items '); + dispatch(fetchLineItemsRequest()); + transactionLineItems({ bookingData, listingId, isOwnListing }) + .then(response => { + console.log('Response: ', response); + dispatch(fetchLineItemsSuccess(response)); + }) + .catch(e => { + dispatch(fetchLineItemsError(storableError(e))); + console.log('e', e); + }); +}; + export const loadData = (params, search) => dispatch => { const listingId = new UUID(params.id); From 6cce04d62f91ed522fae4d45b1e70831d0b140b4 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 9 Jun 2020 14:44:23 +0300 Subject: [PATCH 03/13] Use fetched lineItems in estimated transaction --- src/components/BookingPanel/BookingPanel.js | 17 +++- .../FieldDateRangeInput/DateRangeInput.css | 4 + .../ListingPage/ListingPage.duck.js | 11 ++- src/containers/ListingPage/ListingPage.js | 29 +++++- .../BookingDatesForm/BookingDatesForm.css | 6 +- .../BookingDatesForm/BookingDatesForm.js | 91 +++++++++++++++---- .../EstimatedBreakdownMaybe.js | 83 +++++++++-------- src/translations/en.json | 1 + 8 files changed, 177 insertions(+), 65 deletions(-) diff --git a/src/components/BookingPanel/BookingPanel.js b/src/components/BookingPanel/BookingPanel.js index 62d9f01023..6f1651c5b1 100644 --- a/src/components/BookingPanel/BookingPanel.js +++ b/src/components/BookingPanel/BookingPanel.js @@ -2,7 +2,7 @@ import React from 'react'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { intlShape, injectIntl, FormattedMessage } from '../../util/reactIntl'; -import { arrayOf, bool, func, node, oneOfType, shape, string } from 'prop-types'; +import { arrayOf, array, bool, func, node, oneOfType, shape, string } from 'prop-types'; import classNames from 'classnames'; import omit from 'lodash/omit'; import { propTypes, LISTING_STATE_CLOSED, LINE_ITEM_NIGHT, LINE_ITEM_DAY } from '../../util/types'; @@ -65,6 +65,10 @@ const BookingPanel = props => { history, location, intl, + onFetchTransactionLineItems, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = props; const price = listing.attributes.price; @@ -122,9 +126,14 @@ const BookingPanel = props => { unitType={unitType} onSubmit={onSubmit} price={price} + listingId={listing.id} isOwnListing={isOwnListing} timeSlots={timeSlots} fetchTimeSlotsError={fetchTimeSlotsError} + onFetchTransactionLineItems={onFetchTransactionLineItems} + lineItems={lineItems} + fetchLineItemsInProgress={fetchLineItemsInProgress} + fetchLineItemsError={fetchLineItemsError} /> ) : null} @@ -164,6 +173,8 @@ BookingPanel.defaultProps = { unitType: config.bookingUnitType, timeSlots: null, fetchTimeSlotsError: null, + lineItems: null, + fetchLineItemsError: null, }; BookingPanel.propTypes = { @@ -180,6 +191,10 @@ BookingPanel.propTypes = { onManageDisableScrolling: func.isRequired, timeSlots: arrayOf(propTypes.timeSlot), fetchTimeSlotsError: propTypes.error, + onFetchTransactionLineItems: func.isRequired, + lineItems: array, + fetchLineItemsInProgress: bool.isRequired, + fetchLineItemsError: propTypes.error, // from withRouter history: shape({ diff --git a/src/components/FieldDateRangeInput/DateRangeInput.css b/src/components/FieldDateRangeInput/DateRangeInput.css index 2a6a623324..76ca1c4364 100644 --- a/src/components/FieldDateRangeInput/DateRangeInput.css +++ b/src/components/FieldDateRangeInput/DateRangeInput.css @@ -255,6 +255,10 @@ & :global(.CalendarMonth_caption) { text-transform: capitalize; } + + & :global(.DateInput_input__disabled) { + font-style: normal; + } } /** diff --git a/src/containers/ListingPage/ListingPage.duck.js b/src/containers/ListingPage/ListingPage.duck.js index 8f856d181a..54a734a46b 100644 --- a/src/containers/ListingPage/ListingPage.duck.js +++ b/src/containers/ListingPage/ListingPage.duck.js @@ -5,6 +5,7 @@ import { types as sdkTypes } from '../../util/sdkLoader'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { transactionLineItems } from '../../util/api'; +import * as log from '../../util/log'; import { denormalisedResponseEntities } from '../../util/data'; import { TRANSITION_ENQUIRE } from '../../util/transaction'; import { @@ -296,16 +297,18 @@ export const sendEnquiry = (listingId, message) => (dispatch, getState, sdk) => }; export const fetchTransactionLineItems = ({ bookingData, listingId, isOwnListing }) => dispatch => { - console.log('On fetch line items '); dispatch(fetchLineItemsRequest()); transactionLineItems({ bookingData, listingId, isOwnListing }) .then(response => { - console.log('Response: ', response); - dispatch(fetchLineItemsSuccess(response)); + const lineItems = response.data; + dispatch(fetchLineItemsSuccess(lineItems)); }) .catch(e => { dispatch(fetchLineItemsError(storableError(e))); - console.log('e', e); + log.error(e, 'fetching-line-items-failed', { + listingId: listingId.uuid, + bookingData: bookingData, + }); }); }; diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 05892b8414..6745cca037 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -42,7 +42,12 @@ import { } from '../../components'; import { TopbarContainer, NotFoundPage } from '../../containers'; -import { sendEnquiry, loadData, setInitialValues } from './ListingPage.duck'; +import { + sendEnquiry, + loadData, + setInitialValues, + fetchTransactionLineItems, +} from './ListingPage.duck'; import SectionImages from './SectionImages'; import SectionAvatar from './SectionAvatar'; import SectionHeading from './SectionHeading'; @@ -190,6 +195,10 @@ export class ListingPageComponent extends Component { timeSlots, fetchTimeSlotsError, filterConfig, + onFetchTransactionLineItems, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = this.props; const listingId = new UUID(rawParams.id); @@ -463,6 +472,10 @@ export class ListingPageComponent extends Component { onManageDisableScrolling={onManageDisableScrolling} timeSlots={timeSlots} fetchTimeSlotsError={fetchTimeSlotsError} + onFetchTransactionLineItems={onFetchTransactionLineItems} + lineItems={lineItems} + fetchLineItemsInProgress={fetchLineItemsInProgress} + fetchLineItemsError={fetchLineItemsError} /> @@ -487,6 +500,8 @@ ListingPageComponent.defaultProps = { fetchTimeSlotsError: null, sendEnquiryError: null, filterConfig: config.custom.filters, + lineItems: null, + fetchLineItemsError: null, }; ListingPageComponent.propTypes = { @@ -526,6 +541,10 @@ ListingPageComponent.propTypes = { onSendEnquiry: func.isRequired, onInitializeCardPaymentData: func.isRequired, filterConfig: array, + onFetchTransactionLineItems: func.isRequired, + lineItems: array, + fetchLineItemsInProgress: bool.isRequired, + fetchLineItemsError: propTypes.error, }; const mapStateToProps = state => { @@ -538,6 +557,9 @@ const mapStateToProps = state => { fetchTimeSlotsError, sendEnquiryInProgress, sendEnquiryError, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, enquiryModalOpenForListingId, } = state.ListingPage; const { currentUser } = state.user; @@ -566,6 +588,9 @@ const mapStateToProps = state => { fetchReviewsError, timeSlots, fetchTimeSlotsError, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, sendEnquiryInProgress, sendEnquiryError, }; @@ -575,6 +600,8 @@ const mapDispatchToProps = dispatch => ({ onManageDisableScrolling: (componentId, disableScrolling) => dispatch(manageDisableScrolling(componentId, disableScrolling)), callSetInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)), + onFetchTransactionLineItems: (bookingData, listingId, isOwnListing) => + dispatch(fetchTransactionLineItems(bookingData, listingId, isOwnListing)), onSendEnquiry: (listingId, message) => dispatch(sendEnquiry(listingId, message)), onInitializeCardPaymentData: () => dispatch(initializeCardPaymentData()), }); diff --git a/src/forms/BookingDatesForm/BookingDatesForm.css b/src/forms/BookingDatesForm/BookingDatesForm.css index 23a5ea21ae..fc3499614b 100644 --- a/src/forms/BookingDatesForm/BookingDatesForm.css +++ b/src/forms/BookingDatesForm/BookingDatesForm.css @@ -47,7 +47,11 @@ display: inline-block; } -.timeSlotsError { +.spinner { + margin: auto; +} + +.sideBarError { @apply --marketplaceH4FontStyles; color: var(--failColor); margin: 0 24px 12px 24px; diff --git a/src/forms/BookingDatesForm/BookingDatesForm.js b/src/forms/BookingDatesForm/BookingDatesForm.js index 5c022e1287..b189fbef13 100644 --- a/src/forms/BookingDatesForm/BookingDatesForm.js +++ b/src/forms/BookingDatesForm/BookingDatesForm.js @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import { string, bool, arrayOf } from 'prop-types'; +import { string, bool, arrayOf, array, func } from 'prop-types'; import { compose } from 'redux'; -import { Form as FinalForm } from 'react-final-form'; +import { Form as FinalForm, FormSpy } from 'react-final-form'; import { FormattedMessage, intlShape, injectIntl } from '../../util/reactIntl'; import classNames from 'classnames'; import moment from 'moment'; @@ -9,7 +9,7 @@ import { required, bookingDatesRequired, composeValidators } from '../../util/va import { START_DATE, END_DATE } from '../../util/dates'; import { propTypes } from '../../util/types'; import config from '../../config'; -import { Form, PrimaryButton, FieldDateRangeInput } from '../../components'; +import { Form, IconSpinner, PrimaryButton, FieldDateRangeInput } from '../../components'; import EstimatedBreakdownMaybe from './EstimatedBreakdownMaybe'; import css from './BookingDatesForm.css'; @@ -22,6 +22,7 @@ export class BookingDatesFormComponent extends Component { this.state = { focusedInput: null }; this.handleFormSubmit = this.handleFormSubmit.bind(this); this.onFocusedInputChange = this.onFocusedInputChange.bind(this); + this.handleOnChange = this.handleOnChange.bind(this); } // Function that can be passed to nested components @@ -47,6 +48,25 @@ export class BookingDatesFormComponent extends Component { } } + // When the values of the form are updated we need to fetch + // lineItems from FTW backend for the EstimatedTransactionMaybe + // In case you add more fields to the form, make sure you add + // the values here to the bookingData object. + handleOnChange(formValues) { + const { startDate, endDate } = + formValues.values && formValues.values.bookingDates ? formValues.values.bookingDates : {}; + const listingId = this.props.listingId; + const isOwnListing = this.props.isOwnListing; + + if (startDate && endDate && !this.props.fetchLineItemsInProgress) { + this.props.onFetchTransactionLineItems({ + bookingData: { startDate, endDate }, + listingId, + isOwnListing, + }); + } + } + render() { const { rootClassName, className, price: unitPrice, ...rest } = this.props; const classes = classNames(rootClassName || css.root, className); @@ -84,19 +104,25 @@ export class BookingDatesFormComponent extends Component { intl, isOwnListing, submitButtonWrapperClassName, - unitPrice, unitType, values, timeSlots, fetchTimeSlotsError, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = fieldRenderProps; const { startDate, endDate } = values && values.bookingDates ? values.bookingDates : {}; const bookingStartLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingStartTitle', }); - const bookingEndLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingEndTitle' }); - const requiredMessage = intl.formatMessage({ id: 'BookingDatesForm.requiredDate' }); + const bookingEndLabel = intl.formatMessage({ + id: 'BookingDatesForm.bookingEndTitle', + }); + const requiredMessage = intl.formatMessage({ + id: 'BookingDatesForm.requiredDate', + }); const startDateErrorMessage = intl.formatMessage({ id: 'FieldDateRangeInput.invalidStartDate', }); @@ -104,36 +130,47 @@ export class BookingDatesFormComponent extends Component { id: 'FieldDateRangeInput.invalidEndDate', }); const timeSlotsError = fetchTimeSlotsError ? ( -

+

) : null; - // This is the place to collect breakdown estimation data. See the - // EstimatedBreakdownMaybe component to change the calculations - // for customized payment processes. + // This is the place to collect breakdown estimation data. + // Note: lineItems are calculated and fetched from FTW backend + // so we need to pass only booking data that is needed otherwise + // If you have added new fields to the form that will affect to pricing, + // you need to add the values to handleOnChange function const bookingData = startDate && endDate ? { unitType, - unitPrice, startDate, endDate, - - // NOTE: If unitType is `line-item/units`, a new picker - // for the quantity should be added to the form. - quantity: 1, } : null; - const bookingInfo = bookingData ? ( + + const showEstimatedBreakdown = + bookingData && lineItems && !fetchLineItemsInProgress && !fetchLineItemsError; + + const bookingInfoMaybe = showEstimatedBreakdown ? (

- +
) : null; + const loadingSpinnerMaybe = fetchLineItemsInProgress ? ( + + ) : null; + + const bookingInfoErrorMaybe = fetchLineItemsError ? ( + + + + ) : null; + const dateFormatOptions = { weekday: 'short', month: 'short', @@ -157,6 +194,12 @@ export class BookingDatesFormComponent extends Component { return (
{timeSlotsError} + { + this.handleOnChange(values); + }} + /> - {bookingInfo} + + {bookingInfoMaybe} + {loadingSpinnerMaybe} + {bookingInfoErrorMaybe} +

{ - const numericPrice = convertMoneyToNumber(unitPrice); - const numericTotalPrice = new Decimal(numericPrice).times(unitCount).toNumber(); +const estimatedTotalPrice = lineItems => { + const numericTotalPrice = lineItems.reduce((sum, lineItem) => { + const numericPrice = convertMoneyToNumber(lineItem.lineTotal); + return new Decimal(numericPrice).add(sum); + }, 0); + + // All the lineItems should have same currency so we can use the first one to check that + // In case there are no lineItems we use currency from config.js as default + const currency = + lineItems[0] && lineItems[0].unitPrice ? lineItems[0].unitPrice.currency : config.currency; + return new Money( - convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), - unitPrice.currency + convertUnitToSubUnit(numericTotalPrice.toNumber(), unitDivisor(currency)), + currency ); }; // When we cannot speculatively initiate a transaction (i.e. logged -// out), we must estimate the booking breakdown. This function creates +// out), we must estimate the transaction for booking breakdown. This function creates // an estimated transaction object for that use case. -const estimatedTransaction = (unitType, bookingStart, bookingEnd, unitPrice, quantity) => { +// +// We need to use FTW backend to calculate the correct line items through thransactionLineItems +// endpoint so that they can be passed to this estimated transaction. +const estimatedTransaction = (bookingStart, bookingEnd, lineItems, userRole) => { const now = new Date(); - const isNightly = unitType === LINE_ITEM_NIGHT; - const isDaily = unitType === LINE_ITEM_DAY; - const unitCount = isNightly - ? nightsBetween(bookingStart, bookingEnd) - : isDaily - ? daysBetween(bookingStart, bookingEnd) - : quantity; + const isCustomer = userRole === 'customer'; - const totalPrice = estimatedTotalPrice(unitPrice, unitCount); + const customerLineItems = lineItems.filter(item => item.includeFor.includes('customer')); + const providerLineItems = lineItems.filter(item => item.includeFor.includes('provider')); + + const payinTotal = estimatedTotalPrice(customerLineItems); + const payoutTotal = estimatedTotalPrice(providerLineItems); // bookingStart: "Fri Mar 30 2018 12:00:00 GMT-1100 (SST)" aka "Fri Mar 30 2018 23:00:00 GMT+0000 (UTC)" // Server normalizes night/day bookings to start from 00:00 UTC aka "Thu Mar 29 2018 13:00:00 GMT-1100 (SST)" @@ -87,18 +97,9 @@ const estimatedTransaction = (unitType, bookingStart, bookingEnd, unitPrice, qua createdAt: now, lastTransitionedAt: now, lastTransition: TRANSITION_REQUEST_PAYMENT, - payinTotal: totalPrice, - payoutTotal: totalPrice, - lineItems: [ - { - code: unitType, - includeFor: ['customer', 'provider'], - unitPrice: unitPrice, - quantity: new Decimal(unitCount), - lineTotal: totalPrice, - reversal: false, - }, - ], + payinTotal, + payoutTotal, + lineItems: isCustomer ? customerLineItems : providerLineItems, transitions: [ { createdAt: now, @@ -119,26 +120,28 @@ const estimatedTransaction = (unitType, bookingStart, bookingEnd, unitPrice, qua }; const EstimatedBreakdownMaybe = props => { - const { unitType, unitPrice, startDate, endDate, quantity } = props.bookingData; - const isUnits = unitType === LINE_ITEM_UNITS; - const quantityIfUsingUnits = !isUnits || Number.isInteger(quantity); - const canEstimatePrice = startDate && endDate && unitPrice && quantityIfUsingUnits; - if (!canEstimatePrice) { - return null; - } + const { unitType, startDate, endDate } = props.bookingData; + const lineItems = props.lineItems; - const tx = estimatedTransaction(unitType, startDate, endDate, unitPrice, quantity); + // Currently the estimated breakdown is used only on ListingPage where we want to + // show the breakdown for customer so we can use hard-coded value here + const userRole = 'customer'; - return ( + const tx = + startDate && endDate && lineItems + ? estimatedTransaction(startDate, endDate, lineItems, userRole) + : null; + + return tx ? ( - ); + ) : null; }; export default EstimatedBreakdownMaybe; diff --git a/src/translations/en.json b/src/translations/en.json index 970bec6fa8..2d3aec25cc 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -63,6 +63,7 @@ "BookingBreakdown.total": "Total price", "BookingDatesForm.bookingEndTitle": "End date", "BookingDatesForm.bookingStartTitle": "Start date", + "BookingDatesForm.fetchLineItemsError": "Oops, something went wrong. Please refresh the page and try again.", "BookingDatesForm.listingCurrencyInvalid": "Oops, the currency of the listing doesn't match the currency of the marketplace.", "BookingDatesForm.listingPriceMissing": "Oops, this listing has no price!", "BookingDatesForm.ownListing": "You won't be able to book your own listing.", From 7766906ce62d58f41130e5c392e4213a8ab9e6ab Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 9 Jun 2020 14:45:18 +0300 Subject: [PATCH 04/13] Changes to BookingBreakdown component --- .../BookingBreakdown/BookingBreakdown.js | 2 +- .../LineItemUnknownItemsMaybe.js | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/BookingBreakdown/BookingBreakdown.js b/src/components/BookingBreakdown/BookingBreakdown.js index 176d6867d5..a49b866046 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.js +++ b/src/components/BookingBreakdown/BookingBreakdown.js @@ -92,7 +92,7 @@ export const BookingBreakdownComponent = props => { - + { - const { transaction, intl } = props; + const { transaction, isProvider, intl } = props; // resolve unknown non-reversal line items - const items = transaction.attributes.lineItems.filter( + const allItems = transaction.attributes.lineItems.filter( item => LINE_ITEMS.indexOf(item.code) === -1 && !item.reversal ); + const items = isProvider + ? allItems.filter(item => item.includeFor.includes('provider')) + : allItems.filter(item => item.includeFor.includes('customer')); + return items.length > 0 ? ( {items.map((item, i) => { - const label = humanizeLineItemCode(item.code); + const quantity = item.quantity; + + const label = + quantity && quantity > 1 + ? `${humanizeLineItemCode(item.code)} x ${quantity}` + : humanizeLineItemCode(item.code); + const formattedTotal = formatMoney(intl, item.lineTotal); return (

From d6e14d87c974b7c28d716fe2130722c066fe95af Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Wed, 10 Jun 2020 15:46:08 +0300 Subject: [PATCH 05/13] Fix tests and examples --- server/api-util/lineItemHelpers.test.js | 8 +++--- .../BookingPanel/BookingPanel.example.js | 4 +++ .../ListingPage/ListingPage.test.js | 2 ++ .../__snapshots__/ListingPage.test.js.snap | 4 +++ .../BookingDatesForm.example.js | 2 ++ .../BookingDatesForm/BookingDatesForm.test.js | 28 +++++++++++++++---- .../BookingDatesForm.test.js.snap | 26 +++++++++++++++++ 7 files changed, 65 insertions(+), 9 deletions(-) diff --git a/server/api-util/lineItemHelpers.test.js b/server/api-util/lineItemHelpers.test.js index e98af4093a..7c634fc11a 100644 --- a/server/api-util/lineItemHelpers.test.js +++ b/server/api-util/lineItemHelpers.test.js @@ -131,7 +131,7 @@ describe('calculateTotalFromLineItems()', () => { it('should calculate total of given lineItems lineTotals', () => { const lineItems = [ { - code: 'line-item/nights', + code: 'line-item/night', unitPrice: new Money(10000, 'USD'), quantity: 3, includeFor: ['customer', 'provider'], @@ -152,7 +152,7 @@ describe('calculateTotalForProvider()', () => { it('should calculate total of lineItems where includeFor includes provider', () => { const lineItems = [ { - code: 'line-item/nights', + code: 'line-item/night', unitPrice: new Money(5000, 'USD'), units: 3, seats: 2, @@ -193,7 +193,7 @@ describe('calculateTotalForCustomer()', () => { it('should calculate total of lineItems where includeFor includes customer', () => { const lineItems = [ { - code: 'line-item/nights', + code: 'line-item/night', unitPrice: new Money(5000, 'USD'), units: 3, seats: 2, @@ -234,7 +234,7 @@ describe('constructValidLineItems()', () => { it('should add lineTotal and reversal attributes to the lineItem', () => { const lineItems = [ { - code: 'line-item/nights', + code: 'line-item/night', unitPrice: new Money(5000, 'USD'), quantity: 2, includeFor: ['customer', 'provider'], diff --git a/src/components/BookingPanel/BookingPanel.example.js b/src/components/BookingPanel/BookingPanel.example.js index 0acdc0b929..63d2db6eef 100644 --- a/src/components/BookingPanel/BookingPanel.example.js +++ b/src/components/BookingPanel/BookingPanel.example.js @@ -14,6 +14,8 @@ export const Default = { subTitle: 'Hosted by Author N', authorDisplayName: 'Author Name', onManageDisableScrolling: () => null, + fetchLineItemsInProgress: false, + onFetchTransactionLineItems: () => null, }, }; @@ -27,5 +29,7 @@ export const WithClosedListing = { subTitle: 'Hosted by Author N', authorDisplayName: 'Author Name', onManageDisableScrolling: () => null, + fetchLineItemsInProgress: false, + onFetchTransactionLineItems: () => null, }, }; diff --git a/src/containers/ListingPage/ListingPage.test.js b/src/containers/ListingPage/ListingPage.test.js index 263d7388b8..6a9b354ec9 100644 --- a/src/containers/ListingPage/ListingPage.test.js +++ b/src/containers/ListingPage/ListingPage.test.js @@ -105,6 +105,8 @@ describe('ListingPage', () => { sendEnquiryInProgress: false, onSendEnquiry: noop, filterConfig, + fetchLineItemsInProgress: false, + onFetchTransactionLineItems: () => null, }; const tree = renderShallow(); diff --git a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap index aa6307372b..10da971d69 100644 --- a/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap +++ b/src/containers/ListingPage/__snapshots__/ListingPage.test.js.snap @@ -279,8 +279,11 @@ exports[`ListingPage matches snapshot 1`] = `
null, }, group: 'forms', }; diff --git a/src/forms/BookingDatesForm/BookingDatesForm.test.js b/src/forms/BookingDatesForm/BookingDatesForm.test.js index bd875e770c..832a4f1b38 100644 --- a/src/forms/BookingDatesForm/BookingDatesForm.test.js +++ b/src/forms/BookingDatesForm/BookingDatesForm.test.js @@ -13,6 +13,16 @@ import EstimatedBreakdownMaybe from './EstimatedBreakdownMaybe'; const { Money } = sdkTypes; const noop = () => null; +const lineItems = [ + { + code: 'line-item/night', + unitPrice: new Money(1099, 'USD'), + units: new Decimal(2), + includeFor: ['customer', 'provider'], + lineTotal: new Money(2198, 'USD'), + reversal: false, + }, +]; describe('BookingDatesForm', () => { it('matches snapshot without selected dates', () => { @@ -26,6 +36,9 @@ describe('BookingDatesForm', () => { bookingDates={{}} startDatePlaceholder="today" endDatePlaceholder="tomorrow" + fetchLineItemsInProgress={false} + onFetchTransactionLineItems={noop} + lineItems={lineItems} /> ); expect(tree).toMatchSnapshot(); @@ -38,7 +51,9 @@ describe('EstimatedBreakdownMaybe', () => { unitType: LINE_ITEM_NIGHT, unitPrice: new Money(1234, 'USD'), }; - expect(renderDeep()).toBeFalsy(); + expect( + renderDeep() + ).toBeFalsy(); }); it('renders nothing if missing end date', () => { const data = { @@ -46,7 +61,9 @@ describe('EstimatedBreakdownMaybe', () => { unitPrice: new Money(1234, 'USD'), startDate: new Date(), }; - expect(renderDeep()).toBeFalsy(); + expect( + renderDeep() + ).toBeFalsy(); }); it('renders breakdown with correct transaction data', () => { const unitPrice = new Money(1099, 'USD'); @@ -58,7 +75,8 @@ describe('EstimatedBreakdownMaybe', () => { startDate, endDate, }; - const tree = shallow(); + + const tree = shallow(); const breakdown = tree.find(BookingBreakdown); const { userRole, unitType, transaction, booking } = breakdown.props(); @@ -75,9 +93,9 @@ describe('EstimatedBreakdownMaybe', () => { expect(transaction.attributes.lineItems).toEqual([ { code: 'line-item/night', - includeFor: ['customer', 'provider'], unitPrice, - quantity: new Decimal(2), + units: new Decimal(2), + includeFor: ['customer', 'provider'], lineTotal: new Money(2198, 'USD'), reversal: false, }, diff --git a/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap b/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap index fab92d533a..377c49a7ea 100644 --- a/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap +++ b/src/forms/BookingDatesForm/__snapshots__/BookingDatesForm.test.js.snap @@ -5,6 +5,8 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = ` bookingDates={Object {}} dispatch={[Function]} endDatePlaceholder="tomorrow" + fetchLineItemsError={null} + fetchLineItemsInProgress={false} intl={ Object { "formatDate": [Function], @@ -18,6 +20,30 @@ exports[`BookingDatesForm matches snapshot without selected dates 1`] = ` } } isOwnListing={false} + lineItems={ + Array [ + Object { + "code": "line-item/night", + "includeFor": Array [ + "customer", + "provider", + ], + "lineTotal": Money { + "_sdkType": "Money", + "amount": 2198, + "currency": "USD", + }, + "reversal": false, + "unitPrice": Money { + "_sdkType": "Money", + "amount": 1099, + "currency": "USD", + }, + "units": "2", + }, + ] + } + onFetchTransactionLineItems={[Function]} onSubmit={[Function]} render={[Function]} startDatePlaceholder="today" From cc9b726f25279ce0ebe4bb96bc38ba122571ef43 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Thu, 11 Jun 2020 14:17:31 +0300 Subject: [PATCH 06/13] Small change to lineItems config --- server/api-util/lineItems.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/api-util/lineItems.js b/server/api-util/lineItems.js index db1ba06de8..6294cc1834 100644 --- a/server/api-util/lineItems.js +++ b/server/api-util/lineItems.js @@ -1,4 +1,6 @@ const { calculateQuantityFromDates, calculateTotalFromLineItems } = require('./lineItemHelpers'); +const { types } = require('sharetribe-flex-sdk'); +const { Money } = types; const unitType = 'line-item/night'; const PROVIDER_COMMISSION_PERCENTAGE = -10; @@ -27,8 +29,18 @@ exports.transactionLineItems = (listing, bookingData) => { const unitPrice = listing.attributes.price; const { startDate, endDate } = bookingData; + /** + * If you want to use pre-defined component and translations for printing the lineItems base price for booking, + * you should use one of the codes: + * line-item/night, line-item/day or line-item/units (translated to persons). + * + * Pre-definded commission components expects line item code to be one of the following: + * 'line-item/provider-commission', 'line-item/customer-commission' + * + * By default BookingBreakdown prints line items inside LineItemUnknownItemsMaybe if the lineItem code is not recognized. */ + const booking = { - code: 'line-item/nights', + code: 'line-item/night', unitPrice, quantity: calculateQuantityFromDates(startDate, endDate, unitType), includeFor: ['customer', 'provider'], From 22d070bd04109d25af9bc594001dfd5ce8605f60 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Fri, 5 Jun 2020 18:15:11 +0300 Subject: [PATCH 07/13] Use privileged transition on checkout page --- src/config.js | 2 +- .../CheckoutPage/CheckoutPage.duck.js | 46 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/config.js b/src/config.js index 78b58df24c..8a67166e92 100644 --- a/src/config.js +++ b/src/config.js @@ -32,7 +32,7 @@ const sortSearchByDistance = false; // // In a way, 'processAlias' defines which transaction process (or processes) // this particular web application is able to handle. -const bookingProcessAlias = 'preauth-nightly-booking/release-1'; +const bookingProcessAlias = 'privileged-custom-pricing/release-1'; // The transaction line item code for the main unit type in bookings. // diff --git a/src/containers/CheckoutPage/CheckoutPage.duck.js b/src/containers/CheckoutPage/CheckoutPage.duck.js index 19df3b2dc8..0ba9f5c6f5 100644 --- a/src/containers/CheckoutPage/CheckoutPage.duck.js +++ b/src/containers/CheckoutPage/CheckoutPage.duck.js @@ -1,5 +1,6 @@ import pick from 'lodash/pick'; import config from '../../config'; +import { initiatePrivileged, transitionPrivileged } from '../../util/api'; import { denormalisedResponseEntities } from '../../util/data'; import { storableError } from '../../util/errors'; import { @@ -161,6 +162,12 @@ export const stripeCustomerError = e => ({ export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => { dispatch(initiateOrderRequest()); + + const bookingData = { + startDate: orderParams.bookingStart, + endDate: orderParams.bookingEnd, + }; + const bodyParams = transactionId ? { id: transactionId, @@ -177,9 +184,34 @@ export const initiateOrder = (orderParams, transactionId) => (dispatch, getState expand: true, }; - const createOrder = transactionId ? sdk.transactions.transition : sdk.transactions.initiate; + if (!transactionId) { + return initiatePrivileged({ isSpeculative: false, bookingData, bodyParams, queryParams }) + .then(response => { + const entities = denormalisedResponseEntities(response); + const order = entities[0]; + dispatch(initiateOrderSuccess(order)); + dispatch(fetchCurrentUserHasOrdersSuccess(true)); + return order; + }) + .catch(e => { + dispatch(initiateOrderError(storableError(e))); + const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {}; + log.error(e, 'initiate-order-failed', { + ...transactionIdMaybe, + listingId: orderParams.listingId.uuid, + bookingStart: orderParams.bookingStart, + bookingEnd: orderParams.bookingEnd, + }); + throw e; + }); + } - return createOrder(bodyParams, queryParams) + return transitionPrivileged({ + isSpeculative: false, + bookingData, + bodyParams, + queryParams, + }) .then(response => { const entities = denormalisedResponseEntities(response); const order = entities[0]; @@ -261,6 +293,12 @@ export const sendMessage = params => (dispatch, getState, sdk) => { */ export const speculateTransaction = params => (dispatch, getState, sdk) => { dispatch(speculateTransactionRequest()); + + const bookingData = { + startDate: params.bookingStart, + endDate: params.bookingEnd, + }; + const bodyParams = { transition: TRANSITION_REQUEST_PAYMENT, processAlias: config.bookingProcessAlias, @@ -273,8 +311,8 @@ export const speculateTransaction = params => (dispatch, getState, sdk) => { include: ['booking', 'provider'], expand: true, }; - return sdk.transactions - .initiateSpeculative(bodyParams, queryParams) + + return initiatePrivileged({ isSpeculative: true, bookingData, bodyParams, queryParams }) .then(response => { const entities = denormalisedResponseEntities(response); if (entities.length !== 1) { From 8bc6e4ae852ae1d742d8ddf0c56105ecf74289f1 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jun 2020 22:05:23 +0300 Subject: [PATCH 08/13] Add support for async tx line items in enquiry -> request --- .../TransactionPanel/TransactionPanel.js | 16 +++++++ .../TransactionPage/TransactionPage.duck.js | 42 +++++++++++++++++++ .../TransactionPage/TransactionPage.js | 27 +++++++++++- .../TransactionPage.test.js.snap | 4 ++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/components/TransactionPanel/TransactionPanel.js b/src/components/TransactionPanel/TransactionPanel.js index 3f35870a65..51e68f0402 100644 --- a/src/components/TransactionPanel/TransactionPanel.js +++ b/src/components/TransactionPanel/TransactionPanel.js @@ -191,6 +191,10 @@ export class TransactionPanelComponent extends Component { timeSlots, fetchTimeSlotsError, nextTransitions, + onFetchTransactionLineItems, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = this.props; const currentTransaction = ensureTransaction(transaction); @@ -444,6 +448,10 @@ export class TransactionPanelComponent extends Component { onManageDisableScrolling={onManageDisableScrolling} timeSlots={timeSlots} fetchTimeSlotsError={fetchTimeSlotsError} + onFetchTransactionLineItems={onFetchTransactionLineItems} + lineItems={lineItems} + fetchLineItemsInProgress={fetchLineItemsInProgress} + fetchLineItemsError={fetchLineItemsError} /> ) : null} ({ payload: e, }); +export const fetchLineItemsRequest = () => ({ type: FETCH_LINE_ITEMS_REQUEST }); +export const fetchLineItemsSuccess = lineItems => ({ + type: FETCH_LINE_ITEMS_SUCCESS, + payload: lineItems, +}); +export const fetchLineItemsError = error => ({ + type: FETCH_LINE_ITEMS_ERROR, + error: true, + payload: error, +}); + // ================ Thunks ================ // const listingRelationship = txResponse => { @@ -600,6 +626,22 @@ export const fetchNextTransitions = id => (dispatch, getState, sdk) => { }); }; +export const fetchTransactionLineItems = ({ bookingData, listingId, isOwnListing }) => dispatch => { + dispatch(fetchLineItemsRequest()); + transactionLineItems({ bookingData, listingId, isOwnListing }) + .then(response => { + const lineItems = response.data; + dispatch(fetchLineItemsSuccess(lineItems)); + }) + .catch(e => { + dispatch(fetchLineItemsError(storableError(e))); + log.error(e, 'fetching-line-items-failed', { + listingId: listingId.uuid, + bookingData: bookingData, + }); + }); +}; + // loadData is a collection of async calls that need to be made // before page has all the info it needs to render itself export const loadData = params => (dispatch, getState) => { diff --git a/src/containers/TransactionPage/TransactionPage.js b/src/containers/TransactionPage/TransactionPage.js index 804a3510cc..d654a43e2f 100644 --- a/src/containers/TransactionPage/TransactionPage.js +++ b/src/containers/TransactionPage/TransactionPage.js @@ -35,6 +35,7 @@ import { sendMessage, sendReview, fetchMoreMessages, + fetchTransactionLineItems, } from './TransactionPage.duck'; import css from './TransactionPage.css'; @@ -78,6 +79,10 @@ export const TransactionPageComponent = props => { processTransitions, callSetInitialValues, onInitializeCardPaymentData, + onFetchTransactionLineItems, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = props; const currentTransaction = ensureTransaction(transaction); @@ -243,6 +248,10 @@ export const TransactionPageComponent = props => { onSubmitBookingRequest={handleSubmitBookingRequest} timeSlots={timeSlots} fetchTimeSlotsError={fetchTimeSlotsError} + onFetchTransactionLineItems={onFetchTransactionLineItems} + lineItems={lineItems} + fetchLineItemsInProgress={fetchLineItemsInProgress} + fetchLineItemsError={fetchLineItemsError} /> ) : ( loadingOrFailedFetching @@ -280,9 +289,11 @@ TransactionPageComponent.defaultProps = { sendMessageError: null, timeSlots: null, fetchTimeSlotsError: null, + lineItems: null, + fetchLineItemsError: null, }; -const { bool, func, oneOf, shape, string, arrayOf, number } = PropTypes; +const { bool, func, oneOf, shape, string, array, arrayOf, number } = PropTypes; TransactionPageComponent.propTypes = { params: shape({ id: string }).isRequired, @@ -311,6 +322,12 @@ TransactionPageComponent.propTypes = { fetchTimeSlotsError: propTypes.error, callSetInitialValues: func.isRequired, onInitializeCardPaymentData: func.isRequired, + onFetchTransactionLineItems: func.isRequired, + + // line items + lineItems: array, + fetchLineItemsInProgress: bool.isRequired, + fetchLineItemsError: propTypes.error, // from withRouter history: shape({ @@ -346,6 +363,9 @@ const mapStateToProps = state => { timeSlots, fetchTimeSlotsError, processTransitions, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, } = state.TransactionPage; const { currentUser } = state.user; @@ -375,6 +395,9 @@ const mapStateToProps = state => { timeSlots, fetchTimeSlotsError, processTransitions, + lineItems, + fetchLineItemsInProgress, + fetchLineItemsError, }; }; @@ -390,6 +413,8 @@ const mapDispatchToProps = dispatch => { dispatch(sendReview(role, tx, reviewRating, reviewContent)), callSetInitialValues: (setInitialValues, values) => dispatch(setInitialValues(values)), onInitializeCardPaymentData: () => dispatch(initializeCardPaymentData()), + onFetchTransactionLineItems: (bookingData, listingId, isOwnListing) => + dispatch(fetchTransactionLineItems(bookingData, listingId, isOwnListing)), }; }; diff --git a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap index ad4fca73cd..e3dca06722 100644 --- a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap +++ b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap @@ -47,10 +47,12 @@ exports[`TransactionPage - Order matches snapshot 1`] = ` } declineInProgress={false} declineSaleError={null} + fetchLineItemsError={null} fetchMessagesError={null} fetchMessagesInProgress={false} fetchTimeSlotsError={null} initialMessageFailed={false} + lineItems={null} messages={Array []} oldestMessagePageFetched={0} onAcceptSale={[Function]} @@ -271,9 +273,11 @@ exports[`TransactionPage - Sale matches snapshot 1`] = ` } declineInProgress={false} declineSaleError={null} + fetchLineItemsError={null} fetchMessagesError={null} fetchTimeSlotsError={null} initialMessageFailed={false} + lineItems={null} messages={Array []} oldestMessagePageFetched={0} onAcceptSale={[Function]} From 4f15ad8ae9dd451b92902c097a458e241a6f71e5 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 17 Jun 2020 22:22:32 +0300 Subject: [PATCH 09/13] Speculate transition, not initiate if we already have a tx --- .../CheckoutPage/CheckoutPage.duck.js | 35 ++++++++++++------- src/containers/CheckoutPage/CheckoutPage.js | 17 +++++---- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/containers/CheckoutPage/CheckoutPage.duck.js b/src/containers/CheckoutPage/CheckoutPage.duck.js index 0ba9f5c6f5..a56dd9d002 100644 --- a/src/containers/CheckoutPage/CheckoutPage.duck.js +++ b/src/containers/CheckoutPage/CheckoutPage.duck.js @@ -291,32 +291,43 @@ export const sendMessage = params => (dispatch, getState, sdk) => { * pricing info for the booking breakdown to get a proper estimate for * the price with the chosen information. */ -export const speculateTransaction = params => (dispatch, getState, sdk) => { +export const speculateTransaction = (orderParams, transactionId) => (dispatch, getState, sdk) => { dispatch(speculateTransactionRequest()); const bookingData = { - startDate: params.bookingStart, - endDate: params.bookingEnd, + startDate: orderParams.bookingStart, + endDate: orderParams.bookingEnd, }; - const bodyParams = { - transition: TRANSITION_REQUEST_PAYMENT, - processAlias: config.bookingProcessAlias, - params: { - ...params, - cardToken: 'CheckoutPage_speculative_card_token', - }, + const params = { + ...orderParams, + cardToken: 'CheckoutPage_speculative_card_token', }; + + const bodyParams = transactionId + ? { + id: transactionId, + transition: TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY, + params, + } + : { + transition: TRANSITION_REQUEST_PAYMENT, + processAlias: config.bookingProcessAlias, + params, + }; + const queryParams = { include: ['booking', 'provider'], expand: true, }; - return initiatePrivileged({ isSpeculative: true, bookingData, bodyParams, queryParams }) + const speculate = transactionId ? transitionPrivileged : initiatePrivileged; + + return speculate({ isSpeculative: true, bookingData, bodyParams, queryParams }) .then(response => { const entities = denormalisedResponseEntities(response); if (entities.length !== 1) { - throw new Error('Expected a resource in the sdk.transactions.initiateSpeculative response'); + throw new Error('Expected a resource in the speculate response'); } const tx = entities[0]; dispatch(speculateTransactionSuccess(tx)); diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js index 797978c3ee..cd0d20a7b7 100644 --- a/src/containers/CheckoutPage/CheckoutPage.js +++ b/src/containers/CheckoutPage/CheckoutPage.js @@ -181,6 +181,7 @@ export class CheckoutPageComponent extends Component { if (shouldFetchSpeculatedTransaction) { const listingId = pageData.listing.id; + const transactionId = tx ? tx.id : null; const { bookingStart, bookingEnd } = pageData.bookingDates; // Convert picked date to date that will be converted on the API as @@ -191,11 +192,14 @@ export class CheckoutPageComponent extends Component { // Fetch speculated transaction for showing price in booking breakdown // NOTE: if unit type is line-item/units, quantity needs to be added. // The way to pass it to checkout page is through pageData.bookingData - fetchSpeculatedTransaction({ - listingId, - bookingStart: bookingStartForAPI, - bookingEnd: bookingEndForAPI, - }); + fetchSpeculatedTransaction( + { + listingId, + bookingStart: bookingStartForAPI, + bookingEnd: bookingEndForAPI, + }, + transactionId + ); } this.setState({ pageData: pageData || {}, dataLoaded: true }); @@ -937,7 +941,8 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => ({ dispatch, - fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)), + fetchSpeculatedTransaction: (params, transactionId) => + dispatch(speculateTransaction(params, transactionId)), fetchStripeCustomer: () => dispatch(stripeCustomer()), onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)), onRetrievePaymentIntent: params => dispatch(retrievePaymentIntent(params)), From 3f828273771ad6abe21d54980f7ebd383ecd3980 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 18 Jun 2020 14:06:43 +0300 Subject: [PATCH 10/13] Make tx init/transition with privileged transition optional --- src/config.js | 2 +- .../CheckoutPage/CheckoutPage.duck.js | 175 +++++++++++------- src/util/transaction.js | 12 ++ 3 files changed, 119 insertions(+), 70 deletions(-) diff --git a/src/config.js b/src/config.js index 8a67166e92..78b58df24c 100644 --- a/src/config.js +++ b/src/config.js @@ -32,7 +32,7 @@ const sortSearchByDistance = false; // // In a way, 'processAlias' defines which transaction process (or processes) // this particular web application is able to handle. -const bookingProcessAlias = 'privileged-custom-pricing/release-1'; +const bookingProcessAlias = 'preauth-nightly-booking/release-1'; // The transaction line item code for the main unit type in bookings. // diff --git a/src/containers/CheckoutPage/CheckoutPage.duck.js b/src/containers/CheckoutPage/CheckoutPage.duck.js index a56dd9d002..be05bab922 100644 --- a/src/containers/CheckoutPage/CheckoutPage.duck.js +++ b/src/containers/CheckoutPage/CheckoutPage.duck.js @@ -7,6 +7,7 @@ import { TRANSITION_REQUEST_PAYMENT, TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY, TRANSITION_CONFIRM_PAYMENT, + isPrivileged, } from '../../util/transaction'; import * as log from '../../util/log'; import { fetchCurrentUserHasOrdersSuccess, fetchCurrentUser } from '../../ducks/user.duck'; @@ -163,20 +164,28 @@ export const stripeCustomerError = e => ({ export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => { dispatch(initiateOrderRequest()); + // If we already have a transaction ID, we should transition, not + // initiate. + const isTransition = !!transactionId; + const transition = isTransition + ? TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY + : TRANSITION_REQUEST_PAYMENT; + const isPrivilegedTransition = isPrivileged(transition); + const bookingData = { startDate: orderParams.bookingStart, endDate: orderParams.bookingEnd, }; - const bodyParams = transactionId + const bodyParams = isTransition ? { id: transactionId, - transition: TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY, + transition, params: orderParams, } : { processAlias: config.bookingProcessAlias, - transition: TRANSITION_REQUEST_PAYMENT, + transition, params: orderParams, }; const queryParams = { @@ -184,52 +193,49 @@ export const initiateOrder = (orderParams, transactionId) => (dispatch, getState expand: true, }; - if (!transactionId) { - return initiatePrivileged({ isSpeculative: false, bookingData, bodyParams, queryParams }) - .then(response => { - const entities = denormalisedResponseEntities(response); - const order = entities[0]; - dispatch(initiateOrderSuccess(order)); - dispatch(fetchCurrentUserHasOrdersSuccess(true)); - return order; - }) - .catch(e => { - dispatch(initiateOrderError(storableError(e))); - const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {}; - log.error(e, 'initiate-order-failed', { - ...transactionIdMaybe, - listingId: orderParams.listingId.uuid, - bookingStart: orderParams.bookingStart, - bookingEnd: orderParams.bookingEnd, - }); - throw e; - }); - } + const handleSucces = response => { + const entities = denormalisedResponseEntities(response); + const order = entities[0]; + dispatch(initiateOrderSuccess(order)); + dispatch(fetchCurrentUserHasOrdersSuccess(true)); + return order; + }; - return transitionPrivileged({ - isSpeculative: false, - bookingData, - bodyParams, - queryParams, - }) - .then(response => { - const entities = denormalisedResponseEntities(response); - const order = entities[0]; - dispatch(initiateOrderSuccess(order)); - dispatch(fetchCurrentUserHasOrdersSuccess(true)); - return order; - }) - .catch(e => { - dispatch(initiateOrderError(storableError(e))); - const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {}; - log.error(e, 'initiate-order-failed', { - ...transactionIdMaybe, - listingId: orderParams.listingId.uuid, - bookingStart: orderParams.bookingStart, - bookingEnd: orderParams.bookingEnd, - }); - throw e; + const handleError = e => { + dispatch(initiateOrderError(storableError(e))); + const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {}; + log.error(e, 'initiate-order-failed', { + ...transactionIdMaybe, + listingId: orderParams.listingId.uuid, + bookingStart: orderParams.bookingStart, + bookingEnd: orderParams.bookingEnd, }); + throw e; + }; + + if (isTransition && isPrivilegedTransition) { + // transition privileged + return transitionPrivileged({ isSpeculative: false, bookingData, bodyParams, queryParams }) + .then(handleSucces) + .catch(handleError); + } else if (isTransition) { + // transition non-privileged + return sdk.transactions + .transition(bodyParams, queryParams) + .then(handleSucces) + .catch(handleError); + } else if (isPrivilegedTransition) { + // initiate privileged + return initiatePrivileged({ isSpeculative: false, bookingData, bodyParams, queryParams }) + .then(handleSucces) + .catch(handleError); + } else { + // initiate non-privileged + return sdk.transactions + .initiate(bodyParams, queryParams) + .then(handleSucces) + .catch(handleError); + } }; export const confirmPayment = orderParams => (dispatch, getState, sdk) => { @@ -280,7 +286,8 @@ export const sendMessage = params => (dispatch, getState, sdk) => { }; /** - * Initiate the speculative transaction with the given booking details + * Initiate or transition the speculative transaction with the given + * booking details * * The API allows us to do speculative transaction initiation and * transitions. This way we can create a test transaction and get the @@ -294,6 +301,14 @@ export const sendMessage = params => (dispatch, getState, sdk) => { export const speculateTransaction = (orderParams, transactionId) => (dispatch, getState, sdk) => { dispatch(speculateTransactionRequest()); + // If we already have a transaction ID, we should transition, not + // initiate. + const isTransition = !!transactionId; + const transition = isTransition + ? TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY + : TRANSITION_REQUEST_PAYMENT; + const isPrivilegedTransition = isPrivileged(transition); + const bookingData = { startDate: orderParams.bookingStart, endDate: orderParams.bookingEnd, @@ -304,15 +319,15 @@ export const speculateTransaction = (orderParams, transactionId) => (dispatch, g cardToken: 'CheckoutPage_speculative_card_token', }; - const bodyParams = transactionId + const bodyParams = isTransition ? { id: transactionId, - transition: TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY, + transition, params, } : { - transition: TRANSITION_REQUEST_PAYMENT, processAlias: config.bookingProcessAlias, + transition, params, }; @@ -321,26 +336,48 @@ export const speculateTransaction = (orderParams, transactionId) => (dispatch, g expand: true, }; - const speculate = transactionId ? transitionPrivileged : initiatePrivileged; + const handleSuccess = response => { + const entities = denormalisedResponseEntities(response); + if (entities.length !== 1) { + throw new Error('Expected a resource in the speculate response'); + } + const tx = entities[0]; + dispatch(speculateTransactionSuccess(tx)); + }; - return speculate({ isSpeculative: true, bookingData, bodyParams, queryParams }) - .then(response => { - const entities = denormalisedResponseEntities(response); - if (entities.length !== 1) { - throw new Error('Expected a resource in the speculate response'); - } - const tx = entities[0]; - dispatch(speculateTransactionSuccess(tx)); - }) - .catch(e => { - const { listingId, bookingStart, bookingEnd } = params; - log.error(e, 'speculate-transaction-failed', { - listingId: listingId.uuid, - bookingStart, - bookingEnd, - }); - return dispatch(speculateTransactionError(storableError(e))); + const handleError = e => { + const { listingId, bookingStart, bookingEnd } = params; + log.error(e, 'speculate-transaction-failed', { + listingId: listingId.uuid, + bookingStart, + bookingEnd, }); + return dispatch(speculateTransactionError(storableError(e))); + }; + + if (isTransition && isPrivilegedTransition) { + // transition privileged + return transitionPrivileged({ isSpeculative: true, bookingData, bodyParams, queryParams }) + .then(handleSuccess) + .catch(handleError); + } else if (isTransition) { + // transition non-privileged + return sdk.transactions + .transitionSpeculative(bodyParams, queryParams) + .then(handleSuccess) + .catch(handleError); + } else if (isPrivilegedTransition) { + // initiate privileged + return initiatePrivileged({ isSpeculative: true, bookingData, bodyParams, queryParams }) + .then(handleSuccess) + .catch(handleError); + } else { + // initiate non-privileged + return sdk.transactions + .initiateSpeculative(bodyParams, queryParams) + .then(handleSuccess) + .catch(handleError); + } }; // StripeCustomer is a relantionship to currentUser diff --git a/src/util/transaction.js b/src/util/transaction.js index 21626f870e..9823e73a36 100644 --- a/src/util/transaction.js +++ b/src/util/transaction.js @@ -335,3 +335,15 @@ export const getUserTxRole = (currentUserId, transaction) => { export const txRoleIsProvider = userRole => userRole === TX_TRANSITION_ACTOR_PROVIDER; export const txRoleIsCustomer = userRole => userRole === TX_TRANSITION_ACTOR_CUSTOMER; + +// Check if the given transition is privileged. +// +// Privileged transitions need to be handled from a secure context, +// i.e. the backend. This helper is used to check if the transition +// should go through the local API endpoints, or if using JS SDK is +// enough. +export const isPrivileged = transition => { + return [ + // list privileged transitions here + ].includes(transition); +}; From a861b30c504c9cd92faa61a39012321b9195f427 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Fri, 19 Jun 2020 20:07:16 +0300 Subject: [PATCH 11/13] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index febb40021a..85cd114fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [change] Add UI support for flexible pricing and privileged + transitions. Note that this requires updating the booking breakdown + estimation code that is now done in the backend. + [#1310](https://github.com/sharetribe/ftw-daily/pull/1310) - [add] Add local API endpoints for flexible pricing and privileged transitions [#1301](https://github.com/sharetribe/ftw-daily/pull/1301) - [fix] `yarn run dev-backend` was expecting NODE_ENV. From 33ee91c937802c7647f362d2b00085850af62cf7 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Tue, 23 Jun 2020 18:29:08 +0300 Subject: [PATCH 12/13] Add rounding to privilate-line-item price estimations and remove unnecessary copy-pasted code. --- server/api-util/currency.js | 102 +++++++---------------------- server/api-util/currency.test.js | 60 +++++++++++------ server/api-util/lineItemHelpers.js | 66 +++++++++++-------- 3 files changed, 99 insertions(+), 129 deletions(-) diff --git a/server/api-util/currency.js b/server/api-util/currency.js index 9af2cbeb3b..225cd83735 100644 --- a/server/api-util/currency.js +++ b/server/api-util/currency.js @@ -49,48 +49,6 @@ exports.unitDivisor = currency => { return subUnitDivisors[currency]; }; -////////// Currency manipulation in string format ////////// - -/** - * Ensures that the given string uses only dots or commas - * e.g. ensureSeparator('9999999,99', false) // => '9999999.99' - * - * @param {String} str - string to be formatted - * - * @return {String} converted string - */ -const ensureSeparator = (str, useComma = false) => { - if (typeof str !== 'string') { - throw new TypeError('Parameter must be a string'); - } - return useComma ? str.replace(/\./g, ',') : str.replace(/,/g, '.'); -}; - -/** - * Ensures that the given string uses only dots - * (e.g. JavaScript floats use dots) - * - * @param {String} str - string to be formatted - * - * @return {String} converted string - */ -const ensureDotSeparator = str => { - return ensureSeparator(str, false); -}; - -/** - * Convert string to Decimal object (from Decimal.js math library) - * Handles both dots and commas as decimal separators - * - * @param {String} str - string to be converted - * - * @return {Decimal} numeral value - */ -const convertToDecimal = str => { - const dotFormattedStr = ensureDotSeparator(str); - return new Decimal(dotFormattedStr); -}; - // Divisor can be positive value given as Decimal, Number, or String const convertDivisorToDecimal = divisor => { try { @@ -111,51 +69,16 @@ const isGoogleMathLong = value => { }; /** - * Converts given value to sub unit value and returns it as a number - * - * @param {Number|String} value - * - * @param {Decimal|Number|String} subUnitDivisor - should be something that can be converted to - * Decimal. (This is a ratio between currency's main unit and sub units.) - * - * @param {boolean} useComma - optional. - * Specify if return value should use comma as separator - * - * @return {number} converted value - */ -exports.convertUnitToSubUnit = (value, subUnitDivisor, useComma = false) => { - const subUnitDivisorAsDecimal = convertDivisorToDecimal(subUnitDivisor); - - if (!(typeof value === 'number')) { - throw new TypeError('Value must be number'); - } - - const val = new Decimal(value); - const amount = val.times(subUnitDivisorAsDecimal); - - if (!isSafeNumber(amount)) { - throw new Error( - `Cannot represent money minor unit value ${amount.toString()} safely as a number` - ); - } else if (amount.isInteger()) { - return amount.toNumber(); - } else { - throw new Error(`value must divisible by ${subUnitDivisor}`); - } -}; - -/** - * Convert Money to a number + * Gets subunit amount from Money object and returns it as Decimal. * * @param {Money} value * * @return {Number} converted value */ -exports.convertMoneyToNumber = value => { +exports.getAmountAsDecimalJS = value => { if (!(value instanceof Money)) { throw new Error('Value must be a Money type'); } - const subUnitDivisorAsDecimal = convertDivisorToDecimal(this.unitDivisor(value.currency)); let amount; if (isGoogleMathLong(value.amount)) { @@ -177,5 +100,24 @@ exports.convertMoneyToNumber = value => { ); } - return amount.dividedBy(subUnitDivisorAsDecimal).toNumber(); + return amount; +}; + +/** + * Converts value from DecimalJS to plain JS Number. + * This also checks that Decimal.js value (for Money/amount) + * is not too big or small for JavaScript to handle. + * + * @param {Decimal} value + * + * @return {Number} converted value + */ +exports.convertDecimalJSToNumber = decimalValue => { + if (!isSafeNumber(decimalValue)) { + throw new Error( + `Cannot represent Decimal.js value ${decimalValue.toString()} safely as a number` + ); + } + + return decimalValue.toNumber(); }; diff --git a/server/api-util/currency.test.js b/server/api-util/currency.test.js index da907dd7d6..fec140669f 100644 --- a/server/api-util/currency.test.js +++ b/server/api-util/currency.test.js @@ -1,42 +1,60 @@ const Decimal = require('decimal.js'); const { types } = require('sharetribe-flex-sdk'); const { Money } = types; -const { convertMoneyToNumber, convertUnitToSubUnit } = require('./currency'); +const { convertDecimalJSToNumber, getAmountAsDecimalJS } = require('./currency'); describe('currency utils', () => { - describe('convertUnitToSubUnit(value, subUnitDivisor)', () => { + describe('convertDecimalJSToNumber(value, subUnitDivisor)', () => { const subUnitDivisor = 100; - it('numbers as value', () => { - expect(convertUnitToSubUnit(0, subUnitDivisor)).toEqual(0); - expect(convertUnitToSubUnit(10, subUnitDivisor)).toEqual(1000); - expect(convertUnitToSubUnit(1, subUnitDivisor)).toEqual(100); + it('Decimals as value', () => { + expect(convertDecimalJSToNumber(new Decimal(0), subUnitDivisor)).toEqual(0); + expect(convertDecimalJSToNumber(new Decimal(10), subUnitDivisor)).toEqual(10); }); - it('wrong type', () => { - expect(() => convertUnitToSubUnit({}, subUnitDivisor)).toThrowError('Value must be number'); - expect(() => convertUnitToSubUnit([], subUnitDivisor)).toThrowError('Value must be number'); - expect(() => convertUnitToSubUnit(null, subUnitDivisor)).toThrowError('Value must be number'); + it('Too big Decimals', () => { + const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER || -1 * (2 ** 53 - 1); + const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 2 ** 53 - 1; + expect(() => + convertDecimalJSToNumber(new Decimal(MIN_SAFE_INTEGER - 1), subUnitDivisor) + ).toThrowError('Cannot represent Decimal.js value -9007199254740992 safely as a number'); + expect(() => + convertDecimalJSToNumber(new Decimal(MAX_SAFE_INTEGER + 1), subUnitDivisor) + ).toThrowError('Cannot represent Decimal.js value 9007199254740992 safely as a number'); }); - it('wrong subUnitDivisor', () => { - expect(() => convertUnitToSubUnit(1, 'asdf')).toThrowError(); + it('wrong type', () => { + expect(() => convertDecimalJSToNumber(0, subUnitDivisor)).toThrowError( + 'Value must be a Decimal' + ); + expect(() => convertDecimalJSToNumber(10, subUnitDivisor)).toThrowError( + 'Value must be a Decimal' + ); + expect(() => convertDecimalJSToNumber({}, subUnitDivisor)).toThrowError( + 'Value must be a Decimal' + ); + expect(() => convertDecimalJSToNumber([], subUnitDivisor)).toThrowError( + 'Value must be a Decimal' + ); + expect(() => convertDecimalJSToNumber(null, subUnitDivisor)).toThrowError( + 'Value must be a Decimal' + ); }); }); describe('convertMoneyToNumber(value)', () => { it('Money as value', () => { - expect(convertMoneyToNumber(new Money(10, 'USD'))).toEqual(0.1); - expect(convertMoneyToNumber(new Money(1000, 'USD'))).toEqual(10); - expect(convertMoneyToNumber(new Money(9900, 'USD'))).toEqual(99); - expect(convertMoneyToNumber(new Money(10099, 'USD'))).toEqual(100.99); + expect(getAmountAsDecimalJS(new Money(10, 'USD'))).toEqual(new Decimal(10)); + expect(getAmountAsDecimalJS(new Money(1000, 'USD'))).toEqual(new Decimal(1000)); + expect(getAmountAsDecimalJS(new Money(9900, 'USD'))).toEqual(new Decimal(9900)); + expect(getAmountAsDecimalJS(new Money(10099, 'USD'))).toEqual(new Decimal(10099)); }); it('Wrong type of a parameter', () => { - expect(() => convertMoneyToNumber(10)).toThrowError('Value must be a Money type'); - expect(() => convertMoneyToNumber('10')).toThrowError('Value must be a Money type'); - expect(() => convertMoneyToNumber(true)).toThrowError('Value must be a Money type'); - expect(() => convertMoneyToNumber({})).toThrowError('Value must be a Money type'); - expect(() => convertMoneyToNumber(new Money('asdf', 'USD'))).toThrowError( + expect(() => getAmountAsDecimalJS(10)).toThrowError('Value must be a Money type'); + expect(() => getAmountAsDecimalJS('10')).toThrowError('Value must be a Money type'); + expect(() => getAmountAsDecimalJS(true)).toThrowError('Value must be a Money type'); + expect(() => getAmountAsDecimalJS({})).toThrowError('Value must be a Money type'); + expect(() => getAmountAsDecimalJS(new Money('asdf', 'USD'))).toThrowError( '[DecimalError] Invalid argument' ); }); diff --git a/server/api-util/lineItemHelpers.js b/server/api-util/lineItemHelpers.js index 73681df5ec..2222303ae9 100644 --- a/server/api-util/lineItemHelpers.js +++ b/server/api-util/lineItemHelpers.js @@ -3,7 +3,7 @@ const has = require('lodash/has'); const { types } = require('sharetribe-flex-sdk'); const { Money } = types; -const { convertMoneyToNumber, unitDivisor, convertUnitToSubUnit } = require('./currency'); +const { getAmountAsDecimalJS, convertDecimalJSToNumber } = require('./currency'); const { nightsBetween, daysBetween } = require('./dates'); const LINE_ITEM_NIGHT = 'line-item/night'; const LINE_ITEM_DAY = 'line-item/day'; @@ -20,12 +20,15 @@ const LINE_ITEM_DAY = 'line-item/day'; * @returns {Money} lineTotal */ exports.calculateTotalPriceFromQuantity = (unitPrice, unitCount) => { - const numericPrice = convertMoneyToNumber(unitPrice); - const numericTotalPrice = new Decimal(numericPrice).times(unitCount).toNumber(); - return new Money( - convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), - unitPrice.currency - ); + const amountFromUnitPrice = getAmountAsDecimalJS(unitPrice); + + // NOTE: We round the total price to the nearest integer. + // Payment processors don't support fractional subunits. + const totalPrice = amountFromUnitPrice.times(unitCount).toNearest(1, Decimal.ROUND_HALF_UP); + // Get total price as Number (and validate that the conversion is safe) + const numericTotalPrice = convertDecimalJSToNumber(totalPrice); + + return new Money(numericTotalPrice, unitPrice.currency); }; /** @@ -38,15 +41,19 @@ exports.calculateTotalPriceFromQuantity = (unitPrice, unitCount) => { * @returns {Money} lineTotal */ exports.calculateTotalPriceFromPercentage = (unitPrice, percentage) => { - const numericPrice = convertMoneyToNumber(unitPrice); - const numericTotalPrice = new Decimal(numericPrice) + const amountFromUnitPrice = getAmountAsDecimalJS(unitPrice); + + // NOTE: We round the total price to the nearest integer. + // Payment processors don't support fractional subunits. + const totalPrice = amountFromUnitPrice .times(percentage) .dividedBy(100) - .toNumber(); - return new Money( - convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), - unitPrice.currency - ); + .toNearest(1, Decimal.ROUND_HALF_UP); + + // Get total price as Number (and validate that the conversion is safe) + const numericTotalPrice = convertDecimalJSToNumber(totalPrice); + + return new Money(numericTotalPrice, unitPrice.currency); }; /** @@ -63,15 +70,20 @@ exports.calculateTotalPriceFromSeats = (unitPrice, unitCount, seats) => { if (seats < 0) { throw new Error(`Value of seats can't be negative`); } - const numericPrice = convertMoneyToNumber(unitPrice); - const numericTotalPrice = new Decimal(numericPrice) + + const amountFromUnitPrice = getAmountAsDecimalJS(unitPrice); + + // NOTE: We round the total price to the nearest integer. + // Payment processors don't support fractional subunits. + const totalPrice = amountFromUnitPrice .times(unitCount) .times(seats) - .toNumber(); - return new Money( - convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), - unitPrice.currency - ); + .toNearest(1, Decimal.ROUND_HALF_UP); + + // Get total price as Number (and validate that the conversion is safe) + const numericTotalPrice = convertDecimalJSToNumber(totalPrice); + + return new Money(numericTotalPrice, unitPrice.currency); }; /** @@ -126,18 +138,16 @@ exports.calculateLineTotal = lineItem => { * @retuns {Money} total sum */ exports.calculateTotalFromLineItems = lineItems => { - const numericTotalPrice = lineItems.reduce((sum, lineItem) => { + const totalPrice = lineItems.reduce((sum, lineItem) => { const lineTotal = this.calculateLineTotal(lineItem); - const numericPrice = convertMoneyToNumber(lineTotal); - return new Decimal(numericPrice).add(sum); + return getAmountAsDecimalJS(lineTotal).add(sum); }, 0); + // Get total price as Number (and validate that the conversion is safe) + const numericTotalPrice = convertDecimalJSToNumber(totalPrice); const unitPrice = lineItems[0].unitPrice; - return new Money( - convertUnitToSubUnit(numericTotalPrice.toNumber(), unitDivisor(unitPrice.currency)), - unitPrice.currency - ); + return new Money(numericTotalPrice, unitPrice.currency); }; /** From 20469fcce4b70a6b079f42c4d7f587a71a65a602 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 24 Jun 2020 16:04:37 +0300 Subject: [PATCH 13/13] Add SHARETRIBE_SDK_CLIENT_SECRET= to .env-template --- .env-template | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env-template b/.env-template index 1cad4b4537..e40f7d097e 100644 --- a/.env-template +++ b/.env-template @@ -7,6 +7,11 @@ REACT_APP_SHARETRIBE_SDK_CLIENT_ID=change-me REACT_APP_STRIPE_PUBLISHABLE_KEY= REACT_APP_MAPBOX_ACCESS_TOKEN= +# If you are using a process with privileged transitions, +# Client Secret needs to be set too. The one related to Client ID. +# You get this at Flex Console (Build -> Applications -> Add new). +SHARETRIBE_SDK_CLIENT_SECRET= + # Or set up an alternative map provider (Google Maps). Check documentation. # REACT_APP_GOOGLE_MAPS_API_KEY=