Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Flexible pricing UI #1310

Merged
merged 14 commits into from
Jun 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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