Refactor booking info calculations

This commit is contained in:
Kimmo Puputti 2017-06-29 11:11:13 +03:00
parent e12f2bedd6
commit d909843d4f
18 changed files with 568 additions and 416 deletions

View file

@ -26,12 +26,6 @@ export const BookingBreakdownComponent = props => {
} = props;
const classes = classNames(rootClassName || css.root, className);
const hasSelectedNights = bookingStart && bookingEnd;
// If there's not enough info, render an empty container
if (!hasSelectedNights) {
return <div className={classes} />;
}
const bookingPeriod = (
<FormattedMessage
@ -71,9 +65,7 @@ export const BookingBreakdownComponent = props => {
</div>
);
// Total price can be given (when it comes from API)
// or calculated: sub total - commission
const totalPriceAsNumber = totalPrice ? convertMoneyToNumber(totalPrice, subUnitDivisor) : 0;
const totalPriceAsNumber = convertMoneyToNumber(totalPrice, subUnitDivisor);
const formattedTotalPrice = totalPriceAsNumber
? intl.formatNumber(totalPriceAsNumber, currencyConfig)
: null;
@ -113,11 +105,7 @@ export const BookingBreakdownComponent = props => {
BookingBreakdownComponent.defaultProps = {
rootClassName: null,
className: null,
bookingStart: null,
bookingEnd: null,
unitPrice: null,
commission: null,
totalPrice: null,
};
const { string, instanceOf } = PropTypes;
@ -126,12 +114,12 @@ BookingBreakdownComponent.propTypes = {
rootClassName: string,
className: string,
bookingStart: instanceOf(Date),
bookingEnd: instanceOf(Date),
bookingStart: instanceOf(Date).isRequired,
bookingEnd: instanceOf(Date).isRequired,
unitPrice: propTypes.money,
unitPrice: propTypes.money.isRequired,
totalPrice: propTypes.money.isRequired,
commission: propTypes.money,
totalPrice: propTypes.money,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -8,9 +8,10 @@ describe('BookingBreakdown', () => {
it('pretransaction data matches snapshot', () => {
const tree = renderDeep(
<BookingBreakdownComponent
unitPrice={new types.Money(1000, 'USD')}
bookingStart={new Date(Date.UTC(2017, 3, 14))}
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
unitPrice={new types.Money(1000, 'USD')}
totalPrice={new types.Money(2000, 'USD')}
intl={fakeIntl}
/>
);
@ -20,9 +21,9 @@ describe('BookingBreakdown', () => {
it('customer transaction data matches snapshot', () => {
const tree = renderDeep(
<BookingBreakdownComponent
unitPrice={new types.Money(1000, 'USD')}
bookingStart={new Date(Date.UTC(2017, 3, 14))}
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
unitPrice={new types.Money(1000, 'USD')}
totalPrice={new types.Money(2000, 'USD')}
intl={fakeIntl}
/>
@ -33,9 +34,9 @@ describe('BookingBreakdown', () => {
it('provider transaction data matches snapshot', () => {
const tree = renderDeep(
<BookingBreakdownComponent
unitPrice={new types.Money(1000, 'USD')}
bookingStart={new Date(Date.UTC(2017, 3, 14))}
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
unitPrice={new types.Money(1000, 'USD')}
totalPrice={new types.Money(1800, 'USD')}
commission={new types.Money(200, 'USD')}
intl={fakeIntl}

View file

@ -89,7 +89,9 @@ exports[`BookingBreakdown pretransaction data matches snapshot 1`] = `
</span>
</div>
<div
className={undefined} />
className={undefined}>
20
</div>
</div>
</div>
`;

View file

@ -1,5 +1,9 @@
@import '../../marketplace.css';
.root {
}
.title {
margin: 35px 24px 0 24px;
}

View file

@ -1,17 +1,34 @@
import React, { PropTypes } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { createSlug } from '../../util/urlHelpers';
import { types } from '../../util/sdkLoader';
import { ensureListing, ensureTransaction, ensureBooking, ensureUser } from '../../util/data';
import { BookingBreakdown, NamedLink } from '../../components';
import css from './OrderDetailsPanel.css';
const formatName = (user, defaultName) => {
if (user && user.attributes && user.attributes.profile && user.attributes.profile.firstName) {
return user.attributes.profile.firstName;
const breakdown = transaction => {
const tx = ensureTransaction(transaction);
const listing = ensureListing(tx.listing);
const booking = ensureBooking(tx.booking);
const bookingStart = booking.attributes.start;
const bookingEnd = booking.attributes.end;
const unitPrice = listing.attributes.price;
const totalPrice = tx.attributes.total;
if (!bookingStart || !bookingEnd || !unitPrice || !totalPrice) {
return null;
}
return defaultName;
return (
<BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={unitPrice}
totalPrice={totalPrice}
/>
);
};
const orderTitle = (orderState, listingLink, customerName, lastTransition) => {
@ -105,52 +122,47 @@ const orderMessage = (
const OrderDetailsPanel = props => {
const {
rootClassName,
className,
totalPrice,
orderState,
lastTransitionedAt,
lastTransition,
booking,
listing,
provider,
customer,
transaction,
} = props;
const providerName = formatName(provider, '');
const customerName = formatName(customer, '');
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentProvider = ensureUser(currentTransaction.provider);
const currentCustomer = ensureUser(currentTransaction.customer);
const listingLinkParams = { id: listing.id.uuid, slug: createSlug(listing.attributes.title) };
const listingLink = (
<NamedLink name="ListingPage" params={listingLinkParams}>
{listing.attributes.title}
</NamedLink>
);
const providerName = currentProvider.attributes.profile.firstName;
const customerName = currentCustomer.attributes.profile.firstName;
const transactionState = currentTransaction.attributes.state;
const lastTransitionedAt = currentTransaction.attributes.lastTransitionedAt;
const lastTransition = currentTransaction.attributes.lastTransitione;
// TODO We can't use price from listing, since that might have changed.
// When API includes unit price and possible additional fees, we need to change this.
const unitPrice = listing.attributes.price;
let listingLink = null;
const bookingInfo = unitPrice
? <BookingBreakdown
className={css.receipt}
bookingStart={booking.attributes.start}
bookingEnd={booking.attributes.end}
unitPrice={unitPrice}
totalPrice={totalPrice}
/>
: <p className={css.error}>{'priceRequiredMessage'}</p>;
if (currentListing.id && currentListing.attributes.title) {
const title = currentListing.attributes.title;
const params = { id: currentListing.id.uuid, slug: createSlug(title) };
listingLink = (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
}
// orderState affects to both title and message section
const title = orderTitle(orderState, listingLink, customerName, lastTransition);
const bookingInfo = breakdown(currentTransaction);
const title = orderTitle(transactionState, listingLink, customerName, lastTransition);
const message = orderMessage(
orderState,
transactionState,
listingLink,
providerName,
lastTransitionedAt,
lastTransition
);
const classes = classNames(rootClassName || css.root, className);
return (
<div className={className}>
<div className={classes}>
<h1 className={css.title}>{title}</h1>
<div className={css.message}>
{message}
@ -163,20 +175,18 @@ const OrderDetailsPanel = props => {
);
};
OrderDetailsPanel.defaultProps = { className: null, lastTransition: null };
OrderDetailsPanel.defaultProps = {
rootClassName: null,
className: null,
lastTransition: null,
};
const { instanceOf, string } = PropTypes;
const { string } = PropTypes;
OrderDetailsPanel.propTypes = {
rootClassName: string,
className: string,
totalPrice: instanceOf(types.Money).isRequired,
orderState: string.isRequired,
lastTransitionedAt: instanceOf(Date).isRequired,
lastTransition: string,
booking: propTypes.booking.isRequired,
listing: propTypes.listing.isRequired,
provider: propTypes.user.isRequired,
customer: propTypes.user.isRequired,
transaction: propTypes.transaction.isRequired,
};
export default OrderDetailsPanel;

View file

@ -1,17 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import { createBooking, createListing, createUser } from '../../util/test-data';
import { createBooking, createListing, createUser, createTransaction } from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { BookingBreakdown } from '../../components';
import OrderDetailsPanel from './OrderDetailsPanel.js';
describe('OrderDetailsPanel', () => {
it('matches snapshot', () => {
const { Money } = types;
const props = {
commission: null,
totalPrice: new Money(16500, 'USD'),
orderState: 'state/preauthorized',
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
const tx = createTransaction({
id: 'order-tx',
state: 'state/preauthorized',
total: new Money(16500, 'USD'),
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
@ -20,8 +21,27 @@ describe('OrderDetailsPanel', () => {
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
};
const tree = renderShallow(<OrderDetailsPanel {...props} />);
});
const tree = renderShallow(<OrderDetailsPanel transaction={tx} />);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const { Money } = types;
const tx = createTransaction({
id: 'order-tx',
state: 'state/preauthorized',
total: new Money(16500, 'USD'),
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
new Date(Date.UTC(2017, 5, 13))
),
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
});
const panel = shallow(<OrderDetailsPanel transaction={tx} />);
const breakdownProps = panel.find(BookingBreakdown).props();
expect(breakdownProps.totalPrice).toEqual(new Money(16500, 'USD'));
});
});

View file

@ -1,6 +1,6 @@
exports[`OrderDetailsPanel matches snapshot 1`] = `
<div
className={null}>
className="">
<h1>
<span>
<span>

View file

@ -1,189 +1,237 @@
import React, { PropTypes } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import Decimal from 'decimal.js';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { types } from '../../util/sdkLoader';
import { createSlug } from '../../util/urlHelpers';
import { ensureListing, ensureTransaction, ensureBooking, ensureUser } from '../../util/data';
import { convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency';
import config from '../../config';
import { Avatar, BookingBreakdown, NamedLink } from '../../components';
import css from './SaleDetailsPanel.css';
const SaleDetailsPanel = props => {
const {
className,
subtotalPrice,
saleState,
booking,
lastTransitionedAt,
lastTransition,
listing,
customer,
commission,
} = props;
const { firstName, lastName } = customer.attributes.profile;
const customerName = firstName ? `${firstName} ${lastName}` : '';
// TODO: This is a temporary function to calculate the booking
// price. This should be removed when the API supports dry-runs and we
// can take the total price from the transaction itself.
const estimatedProviderTotalPrice = (customerTotalPrice, commission) => {
const { subUnitDivisor, currency } = config.currencyConfig;
if (customerTotalPrice.currency !== currency || commission.currency !== currency) {
throw new Error('Transaction total or commission currency does not match marketplace currency');
}
const listingLinkParams = { id: listing.id.uuid, slug: createSlug(listing.attributes.title) };
const listingLink = (
<NamedLink name="ListingPage" params={listingLinkParams}>
{listing.attributes.title}
</NamedLink>
);
const numericCustomerTotalPrice = convertMoneyToNumber(customerTotalPrice, subUnitDivisor);
const numericCommission = convertMoneyToNumber(commission, subUnitDivisor);
const numericTotalPrice = new Decimal(numericCustomerTotalPrice)
.minus(numericCommission)
.toNumber();
// TODO We can't use price from listing, since that might have changed.
// When API includes unit price and possible additional fees, we need to change this.
return new types.Money(convertUnitToSubUnit(numericTotalPrice, subUnitDivisor), currency);
};
const breakdown = transaction => {
const tx = ensureTransaction(transaction);
const listing = ensureListing(tx.listing);
const booking = ensureBooking(tx.booking);
const bookingStart = booking.attributes.start;
const bookingEnd = booking.attributes.end;
const unitPrice = listing.attributes.price;
const customerTotalPrice = tx.attributes.total;
const commission = tx.attributes.commission;
const bookingInfo = unitPrice
? <BookingBreakdown
className={css.receipt}
bookingStart={booking.attributes.start}
bookingEnd={booking.attributes.end}
unitPrice={unitPrice}
commission={commission}
subtotalPrice={subtotalPrice}
/>
: <p className={css.error}>{'priceRequiredMessage'}</p>;
if (!bookingStart || !bookingEnd || !unitPrice || !customerTotalPrice || !commission) {
return null;
}
const totalPrice = estimatedProviderTotalPrice(customerTotalPrice, commission);
return (
<BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={unitPrice}
totalPrice={totalPrice}
/>
);
};
const saleTitle = (saleState, listingLink, customerName) => {
switch (saleState) {
case propTypes.TX_STATE_PREAUTHORIZED:
return (
<FormattedMessage
id="SaleDetailsPanel.listingRequestedTitle"
values={{ customerName: customerName, title: listingLink }}
/>
);
case propTypes.TX_STATE_ACCEPTED:
return (
<FormattedMessage
id="SaleDetailsPanel.listingAcceptedTitle"
values={{ customerName: customerName, title: listingLink }}
/>
);
case propTypes.TX_STATE_REJECTED:
return (
<FormattedMessage
id="SaleDetailsPanel.listingRejectedTitle"
values={{ customerName: customerName, title: listingLink }}
/>
);
case propTypes.TX_STATE_DELIVERED:
return (
<FormattedMessage
id="SaleDetailsPanel.listingDeliveredTitle"
values={{ customerName: customerName, title: listingLink }}
/>
);
default:
return null;
}
};
const saleMessage = (saleState, customerName, lastTransitionedAt, lastTransition) => {
const rejectedStatusTranslationId = lastTransition === propTypes.TX_TRANSITION_AUTO_REJECT
? 'SaleDetailsPanel.saleAutoRejectedStatus'
: 'SaleDetailsPanel.saleRejectedStatus';
// saleState affects to both title and message section
let stateMsgData = {};
switch (saleState) {
case propTypes.TX_STATE_PREAUTHORIZED:
stateMsgData = {
title: (
<FormattedMessage
id="SaleDetailsPanel.listingRequestedTitle"
values={{ customerName: customerName, title: listingLink }}
/>
),
message: (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleRequestedStatus" values={{ customerName }} />
</div>
),
};
break;
return (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleRequestedStatus" values={{ customerName }} />
</div>
);
case propTypes.TX_STATE_ACCEPTED:
stateMsgData = {
title: (
return (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleAcceptedStatus" />
<FormattedMessage
id="SaleDetailsPanel.listingAcceptedTitle"
values={{ customerName: customerName, title: listingLink }}
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
),
message: (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleAcceptedStatus" />
<FormattedMessage
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
</div>
),
};
break;
</div>
);
case propTypes.TX_STATE_REJECTED:
stateMsgData = {
title: (
return (
<div className={css.message}>
<FormattedMessage id={rejectedStatusTranslationId} />
<FormattedMessage
id="SaleDetailsPanel.listingRejectedTitle"
values={{ customerName: customerName, title: listingLink }}
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
),
message: (
<div className={css.message}>
<FormattedMessage id={rejectedStatusTranslationId} />
<FormattedMessage
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
</div>
),
};
break;
</div>
);
case propTypes.TX_STATE_DELIVERED:
stateMsgData = {
title: (
return (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleDeliveredStatus" />
<FormattedMessage
id="SaleDetailsPanel.listingDeliveredTitle"
values={{ customerName: customerName, title: listingLink }}
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
),
message: (
<div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleDeliveredStatus" />
<FormattedMessage
id="SaleDetailsPanel.onDate"
values={{
formattedDate: (
<FormattedDate
value={lastTransitionedAt}
year="numeric"
month="short"
day="numeric"
/>
),
}}
/>
</div>
),
};
break;
</div>
);
default:
stateMsgData = { title: null, message: null };
return null;
}
};
const SaleDetailsPanel = props => {
const {
rootClassName,
className,
transaction,
} = props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentCustomer = ensureUser(currentTransaction.customer);
const customerFirstName = currentCustomer.attributes.profile.firstName;
const customerLastName = currentCustomer.attributes.profile.lastName;
const transactionState = currentTransaction.attributes.state;
const lastTransitionedAt = currentTransaction.attributes.lastTransitionedAt;
const lastTransition = currentTransaction.attributes.lastTransition;
let listingLink = null;
if (currentListing.id && currentListing.attributes.title) {
const title = currentListing.attributes.title;
const params = { id: currentListing.id.uuid, slug: createSlug(title) };
listingLink = (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
}
const bookingInfo = breakdown(currentTransaction);
const title = saleTitle(transactionState, listingLink, customerFirstName, lastTransition);
const message = saleMessage(
transactionState,
customerFirstName,
lastTransitionedAt,
lastTransition
);
const classes = classNames(rootClassName || css.root, className);
return (
<div className={className}>
<div className={classes}>
<div className={css.messagesContainer}>
<div className={css.avatarWrapper}>
<Avatar firstName={firstName} lastName={lastName} />
<Avatar firstName={customerFirstName} lastName={customerLastName} />
</div>
<h1 className={css.title}>
{stateMsgData.title}
{title}
</h1>
{stateMsgData.message}
{message}
</div>
{bookingInfo}
</div>
);
};
SaleDetailsPanel.defaultProps = { className: null, lastTransition: null };
SaleDetailsPanel.defaultProps = {
rootClassName: null,
className: null,
lastTransition: null,
};
const { instanceOf, string } = PropTypes;
const { string } = PropTypes;
SaleDetailsPanel.propTypes = {
rootClassName: string,
className: string,
subtotalPrice: instanceOf(types.Money).isRequired,
commission: instanceOf(types.Money).isRequired,
saleState: string.isRequired,
lastTransitionedAt: instanceOf(Date).isRequired,
lastTransition: string,
booking: propTypes.booking.isRequired,
listing: propTypes.listing.isRequired,
customer: propTypes.user.isRequired,
transaction: propTypes.transaction.isRequired,
};
export default SaleDetailsPanel;

View file

@ -1,17 +1,19 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import { createBooking, createListing, createUser } from '../../util/test-data';
import { createTransaction, createBooking, createListing, createUser } from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { BookingBreakdown } from '../../components';
import SaleDetailsPanel from './SaleDetailsPanel.js';
describe('SaleDetailsPanel', () => {
it('matches snapshot', () => {
const { Money } = types;
const props = {
commission: new Money(1650, 'USD'),
subtotalPrice: new Money(16500, 'USD'),
saleState: 'state/preauthorized',
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
const tx = createTransaction({
id: 'sale-tx',
state: 'state/preauthorized',
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
@ -19,8 +21,31 @@ describe('SaleDetailsPanel', () => {
),
listing: createListing('listing1'),
customer: createUser('customer1'),
};
const tree = renderShallow(<SaleDetailsPanel {...props} />);
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
});
const tree = renderShallow(<SaleDetailsPanel transaction={tx} />);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const { Money } = types;
const tx = createTransaction({
id: 'sale-tx',
state: 'state/preauthorized',
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
new Date(Date.UTC(2017, 5, 13))
),
listing: createListing('listing1'),
customer: createUser('customer1'),
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
});
const panel = shallow(<SaleDetailsPanel transaction={tx} />);
const breakdownProps = panel.find(BookingBreakdown).props();
// Total price for the provider should be transaction total minus the commission.
expect(breakdownProps.totalPrice).toEqual(new Money(15500, 'USD'));
});
});

View file

@ -1,6 +1,6 @@
exports[`SaleDetailsPanel matches snapshot 1`] = `
<div
className={null}>
className="">
<div>
<div>
<Avatar
@ -14,7 +14,7 @@ exports[`SaleDetailsPanel matches snapshot 1`] = `
id="SaleDetailsPanel.listingRequestedTitle"
values={
Object {
"customerName": "customer1 first name customer1 last name",
"customerName": "customer1 first name",
"title": <withFlattenedRoutes(withRouter(NamedLink))
name="ListingPage"
params={
@ -33,7 +33,7 @@ exports[`SaleDetailsPanel matches snapshot 1`] = `
id="SaleDetailsPanel.saleRequestedStatus"
values={
Object {
"customerName": "customer1 first name customer1 last name",
"customerName": "customer1 first name",
}
} />
</div>
@ -41,15 +41,9 @@ exports[`SaleDetailsPanel matches snapshot 1`] = `
<BookingBreakdown
bookingEnd={2017-06-13T00:00:00.000Z}
bookingStart={2017-06-10T00:00:00.000Z}
commission={
totalPrice={
Money {
"amount": 1650,
"currency": "USD",
}
}
subtotalPrice={
Money {
"amount": 16500,
"amount": 15500,
"currency": "USD",
}
}

View file

@ -16,6 +16,36 @@ import { Button, BookingBreakdown, DateInputField } from '../../components';
import css from './BookingDatesForm.css';
// TODO: This is a temporary function to calculate the booking
// price. This should be removed when the API supports dry-runs and we
// can take the total price from the transaction itself.
const estimatedTotalPrice = (startDate, endDate, unitPrice) => {
const { subUnitDivisor } = config.currencyConfig;
const numericPrice = convertMoneyToNumber(unitPrice, subUnitDivisor);
const nightCount = nightsBetween(startDate, endDate);
const numericTotalPrice = new Decimal(numericPrice).times(nightCount).toNumber();
return new types.Money(
convertUnitToSubUnit(numericTotalPrice, subUnitDivisor),
unitPrice.currency
);
};
const breakdown = (bookingStart, bookingEnd, unitPrice) => {
if (!bookingStart || !bookingEnd || !unitPrice) {
return null;
}
const totalPrice = estimatedTotalPrice(bookingStart, bookingEnd, unitPrice);
return (
<BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={unitPrice}
totalPrice={totalPrice}
/>
);
};
export const BookingDatesFormComponent = props => {
const {
rootClassName,
@ -25,16 +55,16 @@ export const BookingDatesFormComponent = props => {
form,
invalid,
handleSubmit,
price,
price: unitPrice,
pristine,
submitting,
intl,
} = props;
const classes = classNames(rootClassName || css.root, className);
const { subUnitDivisor, currency } = config.currencyConfig;
const { currency: marketplaceCurrency } = config.currencyConfig;
if (!price) {
if (!unitPrice) {
return (
<div className={classes}>
<p className={css.error}>
@ -43,7 +73,7 @@ export const BookingDatesFormComponent = props => {
</div>
);
}
if (price.currency !== currency) {
if (unitPrice.currency !== marketplaceCurrency) {
return (
<div className={classes}>
<p className={css.error}>
@ -76,26 +106,7 @@ export const BookingDatesFormComponent = props => {
: {};
const hasBookingInfo = bookingStart && bookingEnd;
// Estimate total price. NOTE: this will change when we can do a
// dry-run to the API and get a proper breakdown of the price.
const numericPrice = convertMoneyToNumber(price, subUnitDivisor);
const nightCount = hasBookingInfo ? nightsBetween(bookingStart, bookingEnd) : 0;
const numericTotalPrice = new Decimal(numericPrice).times(nightCount).toNumber();
const totalPrice = new types.Money(
convertUnitToSubUnit(numericTotalPrice, subUnitDivisor),
currency
);
const bookingInfo = hasBookingInfo
? <BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={price}
totalPrice={totalPrice}
/>
: null;
const bookingInfo = breakdown(bookingStart, bookingEnd, unitPrice);
const submitDisabled = pristine || submitting || invalid || !hasBookingInfo;

View file

@ -5,10 +5,14 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { reduce } from 'lodash';
import moment from 'moment';
import Decimal from 'decimal.js';
import config from '../../config';
import { types } from '../../util/sdkLoader';
import { pathByRouteName } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { nightsBetween } from '../../util/dates';
import { convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency';
import { AuthorInfo, BookingBreakdown, NamedRedirect, PageLayout } from '../../components';
import { StripePaymentForm } from '../../containers';
import { initiateOrder, setInitialValues } from './CheckoutPage.duck';
@ -17,6 +21,36 @@ import css from './CheckoutPage.css';
const STORAGE_KEY = 'CheckoutPage';
// TODO: This is a temporary function to calculate the booking
// price. This should be removed when the API supports dry-runs and we
// can take the total price from the transaction itself.
const estimatedTotalPrice = (startDate, endDate, unitPrice) => {
const { subUnitDivisor } = config.currencyConfig;
const numericPrice = convertMoneyToNumber(unitPrice, subUnitDivisor);
const nightCount = nightsBetween(startDate, endDate);
const numericTotalPrice = new Decimal(numericPrice).times(nightCount).toNumber();
return new types.Money(
convertUnitToSubUnit(numericTotalPrice, subUnitDivisor),
unitPrice.currency
);
};
const breakdown = (bookingStart, bookingEnd, unitPrice) => {
if (!bookingStart || !bookingEnd || !unitPrice) {
return null;
}
const totalPrice = estimatedTotalPrice(bookingStart, bookingEnd, unitPrice);
return (
<BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={unitPrice}
totalPrice={totalPrice}
/>
);
};
const ensureListingProperties = listing => {
const empty = { id: null, type: 'listing', attributes: {}, author: {}, images: [] };
// assume own properties: id, type, attributes etc.
@ -155,7 +189,6 @@ export class CheckoutPageComponent extends Component {
const pageData = bookingDates && listing ? { bookingDates, listing } : storedData();
const { bookingStart, bookingEnd } = pageData.bookingDates || {};
const currentListing = ensureListingProperties(pageData.listing);
const price = currentListing.attributes.price;
const isOwnListing = currentListing.id &&
currentUser &&
@ -166,10 +199,27 @@ export class CheckoutPageComponent extends Component {
// but show payment form only when user info is loaded.
const showPaymentForm = currentUser && !isOwnListing;
if (!currentListing.id || !price || isOwnListing) {
// Estimate total price. NOTE: this will change when we can do a
// dry-run to the API and get a proper breakdown of the price.
const { currency: marketplaceCurrency } = config.currencyConfig;
const unitPrice = currentListing.attributes.price;
if (!unitPrice) {
throw new Error('Listing has no price');
}
if (unitPrice.currency !== marketplaceCurrency) {
throw new Error(
`Listing currency different from marketplace currency: ${unitPrice.currency}`
);
}
const hasBookingInfo = bookingStart && bookingEnd;
if (!currentListing.id || isOwnListing || !hasBookingInfo) {
// eslint-disable-next-line no-console
console.error(
'Listing, price, or user invalid for checkout, redirecting back to listing page.'
'Listing, user, or dates invalid for checkout, redirecting back to listing page.',
{ currentListing, isOwnListing, hasBookingInfo, bookingStart, bookingEnd }
);
return <NamedRedirect name="ListingPage" params={params} />;
}
@ -189,16 +239,13 @@ export class CheckoutPageComponent extends Component {
</p>
: null;
const bookingInfo = breakdown(bookingStart, bookingEnd, unitPrice);
return (
<PageLayout title={title}>
<h1 className={css.title}>{title}</h1>
<AuthorInfo author={currentListing.author} className={css.authorContainer} />
<BookingBreakdown
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={price}
/>
{bookingInfo}
<section className={css.payment}>
{errorMessage}
<h2 className={css.paymentTitle}>

View file

@ -23,6 +23,12 @@ exports[`CheckoutPage matches snapshot 1`] = `
<BookingBreakdown
bookingEnd={2017-04-16T00:00:00.000Z}
bookingStart={2017-04-14T00:00:00.000Z}
totalPrice={
Money {
"amount": 11000,
"currency": "USD",
}
}
unitPrice={
Money {
"amount": 5500,

View file

@ -4,7 +4,7 @@ import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { NamedRedirect, OrderDetailsPanel, PageLayout } from '../../components';
import * as propTypes from '../../util/propTypes';
import { ensureBooking, ensureListing, ensureTransaction, ensureUser } from '../../util/data';
import { ensureListing, ensureTransaction } from '../../util/data';
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { loadData } from './OrderPage.duck';
@ -31,17 +31,6 @@ export const OrderPageComponent = props => {
return <NamedRedirect name="InboxPage" params={{ tab: 'orders' }} />;
}
const detailsProps = {
totalPrice: currentTransaction.attributes.total,
orderState: currentTransaction.attributes.state,
lastTransitionedAt: currentTransaction.attributes.lastTransitionedAt,
lastTransition: currentTransaction.attributes.lastTransition,
listing: currentListing,
booking: ensureBooking(currentTransaction.booking),
provider: ensureUser(currentTransaction.provider),
customer: ensureUser(currentTransaction.customer),
};
const detailsClassName = classNames(css.tabContent, {
[css.tabContentVisible]: props.tab === 'details',
});
@ -51,7 +40,7 @@ export const OrderPageComponent = props => {
: <h1 className={css.title}><FormattedMessage id="OrderPage.loadingData" /></h1>;
const panel = isDataAvailable && currentTransaction.id
? <OrderDetailsPanel className={detailsClassName} {...detailsProps} />
? <OrderDetailsPanel className={detailsClassName} transaction={currentTransaction} />
: loadingOrFaildFetching;
return (

View file

@ -2,77 +2,82 @@ exports[`OrderPage matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="OrderPage.title">
<OrderDetailsPanel
booking={
Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
}
}
className="undefined"
customer={
Object {
"attributes": Object {
"profile": Object {
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
}
}
lastTransition={null}
lastTransitionedAt={2017-06-01T00:00:00.000Z}
listing={
rootClassName={null}
transaction={
Object {
"attributes": Object {
"address": "listing1 address",
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"commission": Money {
"amount": 100,
"currency": "USD",
},
"title": "listing1 title",
},
"author": null,
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
}
}
orderState="state/preauthorized"
provider={
Object {
"attributes": Object {
"profile": Object {
"firstName": "provider1 first name",
"lastName": "provider1 last name",
"createdAt": 2017-05-01T00:00:00.000Z,
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
"state": "state/preauthorized",
"total": Money {
"amount": 1000,
"currency": "USD",
},
},
"id": UUID {
"uuid": "provider1",
"booking": Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
},
"type": "user",
}
}
totalPrice={
Money {
"amount": 1000,
"currency": "USD",
"customer": Object {
"attributes": Object {
"profile": Object {
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
},
"id": UUID {
"uuid": "tx-order-1",
},
"listing": Object {
"attributes": Object {
"address": "listing1 address",
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"title": "listing1 title",
},
"author": null,
"id": UUID {
"uuid": "listing1",
},
"type": "listing",
},
"provider": Object {
"attributes": Object {
"profile": Object {
"firstName": "provider1 first name",
"lastName": "provider1 last name",
},
},
"id": UUID {
"uuid": "provider1",
},
"type": "user",
},
"type": "transaction",
}
} />
</Connect(withRouter(PageLayout))>

View file

@ -4,7 +4,7 @@ import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import { compose } from 'redux';
import { connect } from 'react-redux';
import * as propTypes from '../../util/propTypes';
import { ensureBooking, ensureListing, ensureTransaction, ensureUser } from '../../util/data';
import { ensureListing, ensureTransaction } from '../../util/data';
import { Button, NamedRedirect, SaleDetailsPanel, PageLayout } from '../../components';
import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { acceptSale, rejectSale, loadData } from './SalePage.duck';
@ -25,7 +25,7 @@ export const SalePageComponent = props => {
} = props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const title = currentListing.attributes.title;
const listingTitle = currentListing.attributes.title;
// Redirect users with someone else's direct link to their own inbox/sales page.
const isDataAvailable = currentUser &&
@ -39,18 +39,6 @@ export const SalePageComponent = props => {
console.error('Tried to access a sale that was not owned by the current user');
return <NamedRedirect name="InboxPage" params={{ tab: 'sales' }} />;
}
const detailsProps = {
subtotalPrice: currentTransaction.attributes.total,
commission: currentTransaction.attributes.commission,
saleState: currentTransaction.attributes.state,
lastTransitionedAt: currentTransaction.attributes.lastTransitionedAt,
lastTransition: currentTransaction.attributes.lastTransition,
listing: currentListing,
booking: ensureBooking(currentTransaction.booking),
customer: ensureUser(currentTransaction.customer),
};
const detailsClassName = classNames(css.tabContent, {
[css.tabContentVisible]: props.tab === 'details',
});
@ -60,7 +48,7 @@ export const SalePageComponent = props => {
: <h1 className={css.title}><FormattedMessage id="SalePage.loadingData" /></h1>;
const panel = isDataAvailable && currentTransaction.id
? <SaleDetailsPanel className={detailsClassName} {...detailsProps} />
? <SaleDetailsPanel className={detailsClassName} transaction={currentTransaction} />
: loadingOrFaildFetching;
const isPreauthorizedState = currentTransaction.attributes.state ===
@ -77,7 +65,7 @@ export const SalePageComponent = props => {
: null;
return (
<PageLayout title={intl.formatMessage({ id: 'SalePage.title' }, { title })}>
<PageLayout title={intl.formatMessage({ id: 'SalePage.title' }, { title: listingTitle })}>
{panel}
{actionButtons}
</PageLayout>

View file

@ -2,69 +2,82 @@ exports[`SalePage matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="SalePage.title">
<SaleDetailsPanel
booking={
Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
}
}
className="undefined"
commission={
Money {
"amount": 100,
"currency": "USD",
}
}
customer={
Object {
"attributes": Object {
"profile": Object {
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
}
}
lastTransition={null}
lastTransitionedAt={2017-06-01T00:00:00.000Z}
listing={
rootClassName={null}
transaction={
Object {
"attributes": Object {
"address": "listing1 address",
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"commission": Money {
"amount": 100,
"currency": "USD",
},
"createdAt": 2017-05-01T00:00:00.000Z,
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
"state": "state/preauthorized",
"total": Money {
"amount": 1000,
"currency": "USD",
},
"title": "listing1 title",
},
"author": null,
"booking": Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
},
"customer": Object {
"attributes": Object {
"profile": Object {
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
},
"id": UUID {
"uuid": "listing1",
"uuid": "tx-sale-1",
},
"images": Array [],
"type": "listing",
}
}
saleState="state/preauthorized"
subtotalPrice={
Money {
"amount": 1000,
"currency": "USD",
"listing": Object {
"attributes": Object {
"address": "listing1 address",
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"title": "listing1 title",
},
"author": null,
"id": UUID {
"uuid": "listing1",
},
"type": "listing",
},
"provider": Object {
"attributes": Object {
"profile": Object {
"firstName": "provider1 first name",
"lastName": "provider1 last name",
},
},
"id": UUID {
"uuid": "provider1",
},
"type": "user",
},
"type": "transaction",
}
} />
<div>

View file

@ -55,12 +55,12 @@ export const createImage = id => ({
});
// Create a user that conforms to the util/propTypes listing schema
export const createListing = (id, author = null) => ({
export const createListing = (id, author = null, price = new Money(5500, 'USD')) => ({
id: new UUID(id),
type: 'listing',
attributes: {
title: `${id} title`,
price: new Money(5500, 'USD'),
price,
description: `${id} description`,
address: `${id} address`,
geolocation: new LatLng(40, 60),
@ -73,6 +73,7 @@ export const createTransaction = options => {
id,
state = 'state/preauthorized',
total = new Money(1000, 'USD'),
commission = new Money(100, 'USD'),
booking = null,
listing = null,
customer = null,
@ -83,7 +84,7 @@ export const createTransaction = options => {
id: new UUID(id),
type: 'transaction',
attributes: {
commission: new Money(100, 'USD'),
commission,
createdAt: new Date(Date.UTC(2017, 4, 1)),
lastTransitionedAt,
state,