Merge pull request #1062 from sharetribe/custom-pricing-support

Add support for arbitrary line item codes
This commit is contained in:
Hannu Lyytikäinen 2019-04-05 14:44:19 +03:00 committed by GitHub
commit b929493f01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 350 additions and 41 deletions

View file

@ -32,6 +32,21 @@
justify-content: space-between;
}
.subTotalLineItem {
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightBold);
margin: 4px 0 1px 0;
@media (--viewportMedium) {
margin: 6px 0 9px 0;
}
display: flex;
flex-direction: row;
justify-content: space-between;
}
.itemLabel {
flex: 1;
margin-top: 1px; /* align with baseline */

View file

@ -497,3 +497,135 @@ export const UnitsType = {
}),
},
};
export const CustomPricing = {
component: BookingBreakdown,
props: {
userRole: 'customer',
unitType: LINE_ITEM_NIGHT,
transaction: exampleTransaction({
payinTotal: new Money(12800, CURRENCY),
payoutTotal: new Money(12600, CURRENCY),
lineItems: [
{
code: 'line-item/night',
includeFor: ['customer', 'provider'],
quantity: new Decimal(2),
unitPrice: new Money(4500, CURRENCY),
lineTotal: new Money(9000, CURRENCY),
reversal: false,
},
{
code: 'line-item/car-cleaning',
includeFor: ['customer', 'provider'],
quantity: new Decimal(1),
unitPrice: new Money(5000, CURRENCY),
lineTotal: new Money(5000, CURRENCY),
reversal: false,
},
{
code: 'line-item/season-discount',
includeFor: ['customer', 'provider'],
percentage: new Decimal(-10),
unitPrice: new Money(14000, CURRENCY),
lineTotal: new Money(-1400, CURRENCY),
reversal: false,
},
{
code: 'line-item/customer-commission',
includeFor: ['customer'],
percentage: new Decimal(10),
unitPrice: new Money(2000, CURRENCY),
lineTotal: new Money(200, CURRENCY),
reversal: false,
},
],
}),
booking: exampleBooking({
start: new Date(Date.UTC(2017, 3, 14)),
end: new Date(Date.UTC(2017, 3, 16)),
}),
},
};
export const CustomPricingWithRefund = {
component: BookingBreakdown,
props: {
userRole: 'customer',
unitType: LINE_ITEM_NIGHT,
transaction: exampleTransaction({
payinTotal: new Money(0, CURRENCY),
payoutTotal: new Money(0, CURRENCY),
lineItems: [
{
code: 'line-item/night',
includeFor: ['customer', 'provider'],
quantity: new Decimal(2),
unitPrice: new Money(4500, CURRENCY),
lineTotal: new Money(9000, CURRENCY),
reversal: false,
},
{
code: 'line-item/night',
includeFor: ['customer', 'provider'],
quantity: new Decimal(-2),
unitPrice: new Money(4500, CURRENCY),
lineTotal: new Money(-9000, CURRENCY),
reversal: true,
},
{
code: 'line-item/car-cleaning',
includeFor: ['customer', 'provider'],
quantity: new Decimal(1),
unitPrice: new Money(5000, CURRENCY),
lineTotal: new Money(5000, CURRENCY),
reversal: false,
},
{
code: 'line-item/car-cleaning',
includeFor: ['customer', 'provider'],
quantity: new Decimal(-1),
unitPrice: new Money(5000, CURRENCY),
lineTotal: new Money(-5000, CURRENCY),
reversal: true,
},
{
code: 'line-item/season-discount',
includeFor: ['customer', 'provider'],
percentage: new Decimal(-10),
unitPrice: new Money(14000, CURRENCY),
lineTotal: new Money(-1400, CURRENCY),
reversal: false,
},
{
code: 'line-item/season-discount',
includeFor: ['customer', 'provider'],
percentage: new Decimal(10),
unitPrice: new Money(14000, CURRENCY),
lineTotal: new Money(1400, CURRENCY),
reversal: true,
},
{
code: 'line-item/customer-commission',
includeFor: ['customer'],
percentage: new Decimal(10),
unitPrice: new Money(2000, CURRENCY),
lineTotal: new Money(200, CURRENCY),
reversal: false,
},
{
code: 'line-item/customer-commission',
includeFor: ['customer'],
percentage: new Decimal(-10),
unitPrice: new Money(2000, CURRENCY),
lineTotal: new Money(-200, CURRENCY),
reversal: true,
},
],
}),
booking: exampleBooking({
start: new Date(Date.UTC(2017, 3, 14)),
end: new Date(Date.UTC(2017, 3, 16)),
}),
},
};

View file

@ -12,7 +12,7 @@ import {
LINE_ITEM_PROVIDER_COMMISSION,
} from '../../util/types';
import LineItemUnitPrice from './LineItemUnitPrice';
import LineItemUnitPriceMaybe from './LineItemUnitPriceMaybe';
import LineItemBookingPeriod from './LineItemBookingPeriod';
import LineItemUnitsMaybe from './LineItemUnitsMaybe';
import LineItemSubTotalMaybe from './LineItemSubTotalMaybe';
@ -22,6 +22,8 @@ import LineItemProviderCommissionMaybe from './LineItemProviderCommissionMaybe';
import LineItemProviderCommissionRefundMaybe from './LineItemProviderCommissionRefundMaybe';
import LineItemRefundMaybe from './LineItemRefundMaybe';
import LineItemTotalPrice from './LineItemTotalPrice';
import LineItemUnknownItemsMaybe from './LineItemUnknownItemsMaybe';
import css from './BookingBreakdown.css';
export const BookingBreakdownComponent = props => {
@ -40,17 +42,19 @@ export const BookingBreakdownComponent = props => {
return (
<div className={classes}>
<LineItemUnitPrice transaction={transaction} unitType={unitType} intl={intl} />
<LineItemUnitPriceMaybe transaction={transaction} unitType={unitType} intl={intl} />
<LineItemBookingPeriod transaction={transaction} booking={booking} unitType={unitType} />
<LineItemUnitsMaybe transaction={transaction} unitType={unitType} />
<LineItemUnknownItemsMaybe transaction={transaction} intl={intl} />
<LineItemSubTotalMaybe
transaction={transaction}
unitType={unitType}
userRole={userRole}
intl={intl}
/>
<LineItemRefundMaybe transaction={transaction} unitType={unitType} intl={intl} />
<LineItemRefundMaybe transaction={transaction} intl={intl} />
<LineItemCustomerCommissionMaybe
transaction={transaction}

View file

@ -159,17 +159,17 @@ describe('BookingBreakdown', () => {
{
code: 'line-item/provider-commission',
includeFor: ['provider'],
quantity: new Decimal(1),
percentage: new Decimal(-10),
lineTotal: new Money(-200, 'USD'),
unitPrice: new Money(-200, 'USD'),
unitPrice: new Money(2000, 'USD'),
reversal: false,
},
{
code: 'line-item/provider-commission',
includeFor: ['provider'],
quantity: new Decimal(-1),
percentage: new Decimal(10),
lineTotal: new Money(200, 'USD'),
unitPrice: new Money(-200, 'USD'),
unitPrice: new Money(2000, 'USD'),
reversal: true,
},
],

View file

@ -59,12 +59,8 @@ const LineItemBookingPeriod = props => {
item => item.code === unitType && !item.reversal
);
if (!unitPurchase) {
throw new Error(`LineItemBookingPeriod: lineItem (${unitType}) missing`);
}
const useQuantityForDayCount = isNightly || isDaily;
const count = useQuantityForDayCount ? unitPurchase.quantity.toFixed() : dayCount;
const count = useQuantityForDayCount && unitPurchase ? unitPurchase.quantity.toFixed() : dayCount;
const unitCountMessage = (
<FormattedHTMLMessage

View file

@ -1,23 +1,62 @@
import React from 'react';
import { FormattedMessage, intlShape } from 'react-intl';
import Decimal from 'decimal.js';
import { formatMoney } from '../../util/currency';
import { propTypes } from '../../util/types';
import { types as sdkTypes } from '../../util/sdkLoader';
import config from '../../config';
import {
propTypes,
LINE_ITEM_CUSTOMER_COMMISSION,
LINE_ITEM_PROVIDER_COMMISSION,
} from '../../util/types';
import css from './BookingBreakdown.css';
const LineItemRefundMaybe = props => {
const { transaction, unitType, intl } = props;
const { Money } = sdkTypes;
const refund = transaction.attributes.lineItems.find(
item => item.code === unitType && item.reversal
/**
* Calculates the total price in sub units for multiple line items.
*/
const lineItemsTotal = lineItems => {
const amount = lineItems.reduce((total, item) => {
return total.plus(item.lineTotal.amount);
}, new Decimal(0));
return new Money(amount, config.currency);
};
/**
* Checks if line item represents commission
*/
const isCommission = lineItem => {
return (
lineItem.code === LINE_ITEM_PROVIDER_COMMISSION ||
lineItem.code === LINE_ITEM_CUSTOMER_COMMISSION
);
};
return refund ? (
/**
* Returns non-commission, reversal line items
*/
const nonCommissionReversalLineItems = transaction => {
return transaction.attributes.lineItems.filter(item => !isCommission(item) && item.reversal);
};
const LineItemRefundMaybe = props => {
const { transaction, intl } = props;
// all non-commission, reversal line items
const refundLineItems = nonCommissionReversalLineItems(transaction);
const refund = lineItemsTotal(refundLineItems);
const formattedRefund = refundLineItems.length > 0 ? formatMoney(intl, refund) : null;
return formattedRefund ? (
<div className={css.lineItem}>
<span className={css.itemLabel}>
<FormattedMessage id="BookingBreakdown.refund" />
</span>
<span className={css.itemValue}>{formatMoney(intl, refund.lineTotal)}</span>
<span className={css.itemValue}>{formattedRefund}</span>
</div>
) : null;
};

View file

@ -1,7 +1,10 @@
import React from 'react';
import { string } from 'prop-types';
import { FormattedMessage, intlShape } from 'react-intl';
import Decimal from 'decimal.js';
import { formatMoney } from '../../util/currency';
import config from '../../config';
import { types as sdkTypes } from '../../util/sdkLoader';
import {
propTypes,
LINE_ITEM_CUSTOMER_COMMISSION,
@ -10,6 +13,35 @@ import {
import css from './BookingBreakdown.css';
const { Money } = sdkTypes;
/**
* Calculates the total price in sub units for multiple line items.
*/
const lineItemsTotal = lineItems => {
const amount = lineItems.reduce((total, item) => {
return total.plus(item.lineTotal.amount);
}, new Decimal(0));
return new Money(amount, config.currency);
};
/**
* Checks if line item represents commission
*/
const isCommission = lineItem => {
return (
lineItem.code === LINE_ITEM_PROVIDER_COMMISSION ||
lineItem.code === LINE_ITEM_CUSTOMER_COMMISSION
);
};
/**
* Returns non-commission, non-reversal line items
*/
const nonCommissionNonReversalLineItems = transaction => {
return transaction.attributes.lineItems.filter(item => !isCommission(item) && !item.reversal);
};
/**
* Checks if a transaction has a commission line-item for
* a given user role.
@ -40,18 +72,16 @@ const LineItemSubTotalMaybe = props => {
// PLEASE NOTE that this assumes that the transaction doesn't have other
// line item types than the defined unit type (e.g. week, month, year).
const showSubTotal = txHasCommission(transaction, userRole) || refund;
const unitPurchase = transaction.attributes.lineItems.find(
item => item.code === unitType && !item.reversal
);
if (!unitPurchase) {
throw new Error(`LineItemSubTotalMaybe: lineItem (${unitType}) missing`);
}
// all non-reversal, non-commission line items
const subTotalLineItems = nonCommissionNonReversalLineItems(transaction);
// line totals of those line items combined
const subTotal = lineItemsTotal(subTotalLineItems);
const formattedSubTotal = formatMoney(intl, unitPurchase.lineTotal);
const formattedSubTotal = subTotalLineItems.length > 0 ? formatMoney(intl, subTotal) : null;
return showSubTotal ? (
<div className={css.lineItem}>
return formattedSubTotal && showSubTotal ? (
<div className={css.subTotalLineItem}>
<span className={css.itemLabel}>
<FormattedMessage id="BookingBreakdown.subTotal" />
</span>

View file

@ -5,7 +5,7 @@ import { LINE_ITEM_NIGHT, LINE_ITEM_DAY, propTypes } from '../../util/types';
import css from './BookingBreakdown.css';
const LineItemUnitPrice = props => {
const LineItemUnitPriceMaybe = props => {
const { transaction, unitType, intl } = props;
const isNightly = unitType === LINE_ITEM_NIGHT;
const isDaily = unitType === LINE_ITEM_DAY;
@ -19,26 +19,22 @@ const LineItemUnitPrice = props => {
item => item.code === unitType && !item.reversal
);
if (!unitPurchase) {
throw new Error(`LineItemUnitPrice: lineItem (${unitType}) missing`);
}
const formattedUnitPrice = unitPurchase ? formatMoney(intl, unitPurchase.unitPrice) : null;
const formattedUnitPrice = formatMoney(intl, unitPurchase.unitPrice);
return (
return formattedUnitPrice ? (
<div className={css.lineItem}>
<span className={css.itemLabel}>
<FormattedMessage id={translationKey} />
</span>
<span className={css.itemValue}>{formattedUnitPrice}</span>
</div>
);
) : null;
};
LineItemUnitPrice.propTypes = {
LineItemUnitPriceMaybe.propTypes = {
transaction: propTypes.transaction.isRequired,
unitType: propTypes.bookingUnitType.isRequired,
intl: intlShape.isRequired,
};
export default LineItemUnitPrice;
export default LineItemUnitPriceMaybe;

View file

@ -0,0 +1,49 @@
/**
* Renders non-reversal line items that are not listed in the
* `LINE_ITEMS` array in util/types.js
*
* The line items are rendered so that the line item code is formatted to human
* readable form and the line total is printed as price.
*
* If you require another kind of presentation for your line items, add them to
* the `LINE_ITEMS` array in util/types.js and create a specific line item
* component for them that can be used in the `BookingBreakdown` component.
*/
import React from 'react';
import { intlShape } from 'react-intl';
import { formatMoney } from '../../util/currency';
import { humanizeLineItemCode } from '../../util/data';
import { LINE_ITEMS, propTypes } from '../../util/types';
import css from './BookingBreakdown.css';
const LineItemUnknownItemsMaybe = props => {
const { transaction, intl } = props;
// resolve unknown non-reversal line items
const items = transaction.attributes.lineItems.filter(
item => LINE_ITEMS.indexOf(item.code) === -1 && !item.reversal
);
return items.length > 0 ? (
<React.Fragment>
{items.map((item, i) => {
const label = humanizeLineItemCode(item.code);
const formattedTotal = formatMoney(intl, item.lineTotal);
return (
<div key={`${i}-item.code`} className={css.lineItem}>
<span className={css.itemLabel}>{label}</span>
<span className={css.itemValue}>{formattedTotal}</span>
</div>
);
})}
</React.Fragment>
) : null;
};
LineItemUnknownItemsMaybe.propTypes = {
transaction: propTypes.transaction.isRequired,
intl: intlShape.isRequired,
};
export default LineItemUnknownItemsMaybe;

View file

@ -277,7 +277,7 @@ export const userDisplayNameAsString = (user, defaultUserDisplayName) => {
*/
export const userDisplayName = (user, bannedUserDisplayName) => {
console.warn(
`Function userDisplayName is deprecated!
`Function userDisplayName is deprecated!
User function userDisplayNameAsString or component UserDisplayName instead.`
);
@ -333,3 +333,21 @@ export const overrideArrays = (objValue, srcValue, key, object, source, stack) =
return srcValue;
}
};
/**
* Humanizes a line item code. Strips the "line-item/" namespace
* definition from the beginnign, replaces dashes with spaces and
* capitalizes the first character.
*
* @param {string} code a line item code
*
* @return {string} returns the line item code humanized
*/
export const humanizeLineItemCode = code => {
if (!/^line-item\/.+/.test(code)) {
throw new Error(`Invalid line item code: ${code}`);
}
const lowercase = code.replace(/^line-item\//, '').replace(/-/g, ' ');
return lowercase.charAt(0).toUpperCase() + lowercase.slice(1);
};

View file

@ -6,6 +6,7 @@ import {
denormalisedEntities,
arrayToFormValues,
formValuesToArray,
humanizeLineItemCode,
} from './data';
const { UUID } = sdkTypes;
@ -329,3 +330,21 @@ describe('data utils', () => {
});
});
});
describe('humanizeLineItemCode', () => {
it('should humanize a line item code', () => {
expect(humanizeLineItemCode('line-item/new-line-item')).toEqual('New line item');
});
it('should capitalize a one word code', () => {
expect(humanizeLineItemCode('line-item/booking')).toEqual('Booking');
});
it('should reject a code with missing namespace', () => {
expect(() => humanizeLineItemCode('new-line-item')).toThrowError(Error);
});
it('should reject a code with missing code value', () => {
expect(() => humanizeLineItemCode('line-item/')).toThrowError(Error);
});
});

View file

@ -297,7 +297,7 @@ export const LINE_ITEM_UNITS = 'line-item/units';
export const LINE_ITEM_CUSTOMER_COMMISSION = 'line-item/customer-commission';
export const LINE_ITEM_PROVIDER_COMMISSION = 'line-item/provider-commission';
const LINE_ITEMS = [
export const LINE_ITEMS = [
LINE_ITEM_NIGHT,
LINE_ITEM_DAY,
LINE_ITEM_UNITS,
@ -307,6 +307,17 @@ const LINE_ITEMS = [
propTypes.bookingUnitType = oneOf([LINE_ITEM_NIGHT, LINE_ITEM_DAY, LINE_ITEM_UNITS]);
const requiredLineItemPropType = (props, propName, componentName) => {
const prop = props[propName];
if (!prop || prop === '') {
return new Error(`Missing line item code prop from ${componentName}.`);
}
if (!/^line-item\/.+/.test(prop)) {
return new Error(`Invalid line item code value ${prop} passed to ${componentName}.`);
}
};
// Denormalised transaction object
propTypes.transaction = shape({
id: propTypes.uuid.isRequired,
@ -323,7 +334,7 @@ propTypes.transaction = shape({
lineItems: arrayOf(
shape({
code: oneOf(LINE_ITEMS).isRequired,
code: requiredLineItemPropType,
includeFor: arrayOf(oneOf(['customer', 'provider'])).isRequired,
quantity: instanceOf(Decimal),
unitPrice: propTypes.money.isRequired,