Skip to content

Commit

Permalink
Merge pull request #1310 from sharetribe/flexible-pricing-ui
Browse files Browse the repository at this point in the history
Flexible pricing UI
  • Loading branch information
lyyder authored Jun 25, 2020
2 parents e7f89a6 + 3b90ffb commit 31f1524
Show file tree
Hide file tree
Showing 31 changed files with 666 additions and 261 deletions.
5 changes: 5 additions & 0 deletions .env-template
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 22 additions & 80 deletions server/api-util/currency.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)) {
Expand All @@ -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();
};
60 changes: 39 additions & 21 deletions server/api-util/currency.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
Expand Down
66 changes: 38 additions & 28 deletions server/api-util/lineItemHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
};

/**
Expand All @@ -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);
};

/**
Expand All @@ -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);
};

/**
Expand Down Expand Up @@ -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);
};

/**
Expand Down
8 changes: 4 additions & 4 deletions server/api-util/lineItemHelpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
Expand Down
Loading

0 comments on commit 31f1524

Please sign in to comment.