From e6d1c1843774687ec33e44facdfc7fa995683f8a Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 4 Jun 2020 22:12:33 +0300 Subject: [PATCH 01/41] Add NODE_ENV to dev-backend script command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1bd6589a6..a427c38a2 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "config": "node scripts/config.js", "config-check": "node scripts/config.js --check", "dev-frontend": "sharetribe-scripts start", - "dev-backend": "DEV_API_SERVER_PORT=3500 nodemon server/apiServer.js", + "dev-backend": "export NODE_ENV=development DEV_API_SERVER_PORT=3500&&nodemon server/apiServer.js", "dev": "yarn run config-check&&concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", "build": "sharetribe-scripts build", "format": "prettier --write '**/*.{js,css}'", From 74168fd32288e6c5e62d647c814fc2c551f1ecf2 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Thu, 4 Jun 2020 22:15:54 +0300 Subject: [PATCH 02/41] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99563e6e8..6c67d4600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [fix] `yarn run dev-backend` was expecting NODE_ENV. + [#1303](https://github.com/sharetribe/ftw-daily/pull/1303) + ## [v5.0.0] 2020-06-04 - [change] Streamlining filter setup. Everyone who customizes FTW-templates, needs to update filters From c24c240153b696480aa9d06f2e7135a5ca738bcb Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Mon, 1 Jun 2020 13:49:44 +0300 Subject: [PATCH 03/41] Use helper functions to construct lineItems in FTW backend --- server/api-util/currency.js | 139 ++++++++++++++++++++ server/api-util/dates.js | 42 ++++++ server/api-util/lineItemHelpers.js | 200 +++++++++++++++++++++++++++++ server/api-util/lineItems.js | 47 +++++++ 4 files changed, 428 insertions(+) create mode 100644 server/api-util/currency.js create mode 100644 server/api-util/dates.js create mode 100644 server/api-util/lineItemHelpers.js create mode 100644 server/api-util/lineItems.js diff --git a/server/api-util/currency.js b/server/api-util/currency.js new file mode 100644 index 000000000..c2c212269 --- /dev/null +++ b/server/api-util/currency.js @@ -0,0 +1,139 @@ +const Decimal = require('decimal.js'); +const has = require('lodash/has'); +const { types } = require('sharetribe-flex-sdk'); +const { Money } = types; + +/** Helper functions for handling currency */ + +// https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER +// https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER +// https://stackoverflow.com/questions/26380364/why-is-number-max-safe-integer-9-007-199-254-740-991-and-not-9-007-199-254-740-9 +const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER || -1 * (2 ** 53 - 1); +const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 2 ** 53 - 1; + +const isSafeNumber = decimalValue => { + if (!(decimalValue instanceof Decimal)) { + throw new Error('Value must be a Decimal'); + } + return decimalValue.gte(MIN_SAFE_INTEGER) && decimalValue.lte(MAX_SAFE_INTEGER); +}; + +// See: https://en.wikipedia.org/wiki/ISO_4217 +// See: https://stripe.com/docs/currencies +const subUnitDivisors = { + AUD: 100, + CAD: 100, + CHF: 100, + CNY: 100, + DKK: 100, + EUR: 100, + GBP: 100, + HKD: 100, + INR: 100, + JPY: 1, + MXN: 100, + NOK: 100, + NZD: 100, + SEK: 100, + SGD: 100, + USD: 100, +}; + +// Get the minor unit divisor for the given currency +exports.unitDivisor = currency => { + if (!has(subUnitDivisors, currency)) { + throw new Error( + `No minor unit divisor defined for currency: ${currency} in /server/api-util/currency.js` + ); + } + return subUnitDivisors[currency]; +}; + +// Divisor can be positive value given as Decimal, Number, or String +const convertDivisorToDecimal = divisor => { + try { + const divisorAsDecimal = new Decimal(divisor); + if (divisorAsDecimal.isNegative()) { + throw new Error(`Parameter (${divisor}) must be a positive number.`); + } + return divisorAsDecimal; + } catch (e) { + throw new Error(`Parameter (${divisor}) must present a number.`, e); + } +}; + +// Detect if the given value is a goog.math.Long object +// See: https://google.github.io/closure-library/api/goog.math.Long.html +const isGoogleMathLong = value => { + return typeof value === 'object' && isNumber(value.low_) && isNumber(value.high_); +}; + +/** + * 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 + * + * @param {Money} value + * + * @return {Number} converted value + */ +exports.convertMoneyToNumber = 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)) { + // TODO: temporarily also handle goog.math.Long values created by + // the Transit tooling in the Sharetribe JS SDK. This should be + // removed when the value.amount will be a proper Decimal type. + + // eslint-disable-next-line no-console + console.warn('goog.math.Long value in money amount:', value.amount, value.amount.toString()); + + amount = new Decimal(value.amount.toString()); + } else { + amount = new Decimal(value.amount); + } + + if (!isSafeNumber(amount)) { + throw new Error( + `Cannot represent money minor unit value ${amount.toString()} safely as a number` + ); + } + + return amount.dividedBy(subUnitDivisorAsDecimal).toNumber(); +}; diff --git a/server/api-util/dates.js b/server/api-util/dates.js new file mode 100644 index 000000000..f4f5739b3 --- /dev/null +++ b/server/api-util/dates.js @@ -0,0 +1,42 @@ +const moment = require('moment'); + +/** Helper functions for handling dates + * These helper functions are copied from src/util/dates.js + */ + +/** + * Calculate the number of nights between the given dates + * + * @param {Date} startDate start of the time period + * @param {Date} endDate end of the time period + * + * @throws Will throw if the end date is before the start date + * @returns {Number} number of nights between the given dates + */ +exports.nightsBetween = (startDate, endDate) => { + const nights = moment(endDate).diff(startDate, 'days'); + if (nights < 0) { + throw new Error('End date cannot be before start date'); + } + return nights; +}; + +/** + * Calculate the number of days between the given dates + * + * @param {Date} startDate start of the time period + * @param {Date} endDate end of the time period. NOTE: with daily + * bookings, it is expected that this date is the exclusive end date, + * i.e. the last day of the booking is the previous date of this end + * date. + * + * @throws Will throw if the end date is before the start date + * @returns {Number} number of days between the given dates + */ +exports.daysBetween = (startDate, endDate) => { + const days = moment(endDate).diff(startDate, 'days'); + if (days < 0) { + throw new Error('End date cannot be before start date'); + } + return days; +}; diff --git a/server/api-util/lineItemHelpers.js b/server/api-util/lineItemHelpers.js new file mode 100644 index 000000000..feaee1108 --- /dev/null +++ b/server/api-util/lineItemHelpers.js @@ -0,0 +1,200 @@ +const Decimal = require('decimal.js'); +const has = require('lodash/has'); +const { types } = require('sharetribe-flex-sdk'); +const { Money } = types; + +const { convertMoneyToNumber, unitDivisor, convertUnitToSubUnit } = require('./currency'); +const { nightsBetween, daysBetween } = require('./dates'); +const LINE_ITEM_NIGHT = 'line-item/night'; +const LINE_ITEM_DAY = 'line-item/day'; + +/** Helper functions for constructing line items*/ + +/** + * Calculates lineTotal for lineItem based on quantity. + * The total will be `unitPrice * quantity`. + * + * @param {Money} unitPrice + * @param {int} percentage + * + * @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 + ); +}; + +/** + * Calculates lineTotal for lineItem based on percentage. + * The total will be `unitPrice * (percentage / 100)`. + * + * @param {Money} unitPrice + * @param {int} percentage + * + * @returns {Money} lineTotal + */ +exports.calculateTotalPriceFromPercentage = (unitPrice, percentage) => { + const numericPrice = convertMoneyToNumber(unitPrice); + const numericTotalPrice = new Decimal(numericPrice) + .times(percentage) + .dividedBy(100) + .toNumber(); + return new Money( + convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), + unitPrice.currency + ); +}; + +/** + * Calculates lineTotal for lineItem based on seats and units. + * The total will be `unitPrice * units * seats`. + * + * @param {Money} unitPrice + * @param {int} unitCount + * @param {int} seats + * + * @returns {Money} lineTotal + */ +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) + .times(unitCount) + .times(seats) + .toNumber(); + return new Money( + convertUnitToSubUnit(numericTotalPrice, unitDivisor(unitPrice.currency)), + unitPrice.currency + ); +}; + +/** + * Calculates the quantity based on the booking start and end dates depending on booking type. + * + * @param {Date} startDate + * @param {Date} endDate + * @param {string} type + * + * @returns {number} quantity + */ +exports.calculateQuantityFromDates = (startDate, endDate, type) => { + if (type === LINE_ITEM_NIGHT) { + return nightsBetween(startDate, endDate); + } else if (type === LINE_ITEM_DAY) { + return daysBetween(startDate, endDate); + } + throw new Error(`Can't calculate quantity from dates to unit type: ${type}`); +}; + +/** + * + * `lineTotal` is calculated by the following rules: + * - If `quantity` is provided, the line total will be `unitPrice * quantity`. + * - If `percentage` is provided, the line total will be `unitPrice * (percentage / 100)`. + * - If `seats` and `units` are provided the line item will contain `quantity` as a product of `seats` and `units` and the line total will be `unitPrice * units * seats`. + * + * @param {Object} lineItem + * @return {Money} lineTotal + * + */ +exports.calculateLineTotal = lineItem => { + const { code, unitPrice, quantity, percentage, seats, units } = lineItem; + + if (quantity) { + return this.calculateTotalPriceFromQuantity(unitPrice, quantity); + } else if (percentage) { + return this.calculateTotalPriceFromPercentage(unitPrice, percentage); + } else if (seats && units) { + return this.calculateTotalPriceFromSeats(unitPrice, units, seats); + } else { + console.error( + "Can't calculate the lineTotal of lineItem: ", + code, + ' Make sure the lineItem has quantity, percentage or both seats and units' + ); + } +}; + +/** + * Calculates the total sum of lineTotals for given lineItems + * + * @param {Array} lineItems + * @retuns {Money} total sum + */ +exports.calculateTotalFromLineItems = lineItems => { + const numericTotalPrice = lineItems.reduce((sum, lineItem) => { + const lineTotal = this.calculateLineTotal(lineItem); + const numericPrice = convertMoneyToNumber(lineTotal); + return new Decimal(numericPrice).add(sum); + }, 0); + + const unitPrice = lineItems[0].unitPrice; + + return new Money( + convertUnitToSubUnit(numericTotalPrice.toNumber(), unitDivisor(unitPrice.currency)), + unitPrice.currency + ); +}; + +/** + * Calculates the total sum of lineTotals for given lineItems where `includeFor` includes `provider` + * @param {*} lineItems + * @returns {Money} total sum + */ +exports.calculateTotalForProvider = lineItems => { + const providerLineItems = lineItems.filter(lineItem => lineItem.includeFor.includes('provider')); + return this.calculateTotalFromLineItems(providerLineItems); +}; + +/** + * Calculates the total sum of lineTotals for given lineItems where `includeFor` includes `customer` + * @param {*} lineItems + * @returns {Money} total sum + */ +exports.calculateTotalForCustomer = lineItems => { + const providerLineItems = lineItems.filter(lineItem => lineItem.includeFor.includes('customer')); + return this.calculateTotalFromLineItems(providerLineItems); +}; + +/** + * Constructs lineItems that can be used directly in FTW. + * This function checks lineItem code and adds attributes like lineTotal and reversal + * which are added in API response and some FTW components are expecting. + * + * This can be used when user is not authenticated and we can't call speculative API endpoints directly + * + * @param {Array} lineItems + * @returns {Array} lineItems with lineTotal and reversal info + * + */ +exports.constructValidLineItems = lineItems => { + const lineItemsWithTotals = lineItems.map(lineItem => { + const { code, quantity, percentage } = lineItem; + + if (!/^line-item\/.+/.test(code)) { + throw new Error(`Invalid line item code: ${code}`); + } + + // lineItems are expected to be in similar format as when they are returned from API + // so that we can use them in e.g. BookingBreakdown component. + // This means we need to convert quantity to Decimal and add attributes lineTotal and reversal to lineItems + const lineTotal = this.calculateLineTotal(lineItem); + return { + ...lineItem, + lineTotal, + quantity: quantity ? new Decimal(quantity) : null, + percentage: percentage ? new Decimal(percentage) : null, + reversal: false, + }; + }); + + //TODO do we want to validate payout and payin sums? + + return lineItemsWithTotals; +}; diff --git a/server/api-util/lineItems.js b/server/api-util/lineItems.js new file mode 100644 index 000000000..db1ba06de --- /dev/null +++ b/server/api-util/lineItems.js @@ -0,0 +1,47 @@ +const { calculateQuantityFromDates, calculateTotalFromLineItems } = require('./lineItemHelpers'); + +const unitType = 'line-item/night'; +const PROVIDER_COMMISSION_PERCENTAGE = -10; + +/** Returns collection of lineItems (max 50) + * + * Each line items has following fields: + * - `code`: string, mandatory, indentifies line item type (e.g. \"line-item/cleaning-fee\"), maximum length 64 characters. + * - `unitPrice`: money, mandatory + * - `lineTotal`: money + * - `quantity`: number + * - `percentage`: number (e.g. 15.5 for 15.5%) + * - `seats`: number + * - `units`: number + * - `includeFor`: array containing strings \"customer\" or \"provider\", default [\":customer\" \":provider\" ] + * + * Line item must have either `quantity` or `percentage` or both `seats` and `units`. + * + * `includeFor` defines commissions. Customer commission is added by defining `includeFor` array `["customer"]` and provider commission by `["provider"]`. + * + * @param {Object} listing + * @param {Object} bookingData + * @returns {Array} lineItems + */ +exports.transactionLineItems = (listing, bookingData) => { + const unitPrice = listing.attributes.price; + const { startDate, endDate } = bookingData; + + const booking = { + code: 'line-item/nights', + unitPrice, + quantity: calculateQuantityFromDates(startDate, endDate, unitType), + includeFor: ['customer', 'provider'], + }; + + const providerCommission = { + code: 'line-item/provider-commission', + unitPrice: calculateTotalFromLineItems([booking]), + percentage: PROVIDER_COMMISSION_PERCENTAGE, + includeFor: ['provider'], + }; + + const lineItems = [booking, providerCommission]; + + return lineItems; +}; From d10a7756e0070e8c58b8ddc0c88d3b915723a612 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 2 Jun 2020 16:21:19 +0300 Subject: [PATCH 04/41] Add tests to util functions --- server/api-util/currency.js | 42 ++++ server/api-util/currency.test.js | 44 ++++ server/api-util/dates.test.js | 45 ++++ server/api-util/lineItemHelpers.js | 11 +- server/api-util/lineItemHelpers.test.js | 263 ++++++++++++++++++++++++ 5 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 server/api-util/currency.test.js create mode 100644 server/api-util/dates.test.js create mode 100644 server/api-util/lineItemHelpers.test.js diff --git a/server/api-util/currency.js b/server/api-util/currency.js index c2c212269..9af2cbeb3 100644 --- a/server/api-util/currency.js +++ b/server/api-util/currency.js @@ -49,6 +49,48 @@ 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 { diff --git a/server/api-util/currency.test.js b/server/api-util/currency.test.js new file mode 100644 index 000000000..da907dd7d --- /dev/null +++ b/server/api-util/currency.test.js @@ -0,0 +1,44 @@ +const Decimal = require('decimal.js'); +const { types } = require('sharetribe-flex-sdk'); +const { Money } = types; +const { convertMoneyToNumber, convertUnitToSubUnit } = require('./currency'); + +describe('currency utils', () => { + describe('convertUnitToSubUnit(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('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('wrong subUnitDivisor', () => { + expect(() => convertUnitToSubUnit(1, 'asdf')).toThrowError(); + }); + }); + + 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); + }); + + 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( + '[DecimalError] Invalid argument' + ); + }); + }); +}); diff --git a/server/api-util/dates.test.js b/server/api-util/dates.test.js new file mode 100644 index 000000000..4fe56dc95 --- /dev/null +++ b/server/api-util/dates.test.js @@ -0,0 +1,45 @@ +const { nightsBetween, daysBetween } = require('./dates'); + +describe('nightsBetween()', () => { + it('should fail if end date is before start date', () => { + const start = new Date(2017, 0, 2); + const end = new Date(2017, 0, 1); + expect(() => nightsBetween(start, end)).toThrow('End date cannot be before start date'); + }); + it('should handle equal start and end dates', () => { + const d = new Date(2017, 0, 1); + expect(nightsBetween(d, d)).toEqual(0); + }); + it('should calculate night count for a single night', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 2); + expect(nightsBetween(start, end)).toEqual(1); + }); + it('should calculate night count', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 3); + expect(nightsBetween(start, end)).toEqual(2); + }); +}); + +describe('daysBetween()', () => { + it('should fail if end date is before start date', () => { + const start = new Date(2017, 0, 2); + const end = new Date(2017, 0, 1); + expect(() => daysBetween(start, end)).toThrow('End date cannot be before start date'); + }); + it('should handle equal start and end dates', () => { + const d = new Date(2017, 0, 1); + expect(daysBetween(d, d)).toEqual(0); + }); + it('should calculate night count for a single day', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 2); + expect(daysBetween(start, end)).toEqual(1); + }); + it('should calculate day count', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 3); + expect(daysBetween(start, end)).toEqual(2); + }); +}); diff --git a/server/api-util/lineItemHelpers.js b/server/api-util/lineItemHelpers.js index feaee1108..73681df5e 100644 --- a/server/api-util/lineItemHelpers.js +++ b/server/api-util/lineItemHelpers.js @@ -15,7 +15,7 @@ const LINE_ITEM_DAY = 'line-item/day'; * The total will be `unitPrice * quantity`. * * @param {Money} unitPrice - * @param {int} percentage + * @param {int} quantity * * @returns {Money} lineTotal */ @@ -113,10 +113,8 @@ exports.calculateLineTotal = lineItem => { } else if (seats && units) { return this.calculateTotalPriceFromSeats(unitPrice, units, seats); } else { - console.error( - "Can't calculate the lineTotal of lineItem: ", - code, - ' Make sure the lineItem has quantity, percentage or both seats and units' + throw new Error( + `Can't calculate the lineTotal of lineItem: ${code}. Make sure the lineItem has quantity, percentage or both seats and units` ); } }; @@ -193,8 +191,5 @@ exports.constructValidLineItems = lineItems => { reversal: false, }; }); - - //TODO do we want to validate payout and payin sums? - return lineItemsWithTotals; }; diff --git a/server/api-util/lineItemHelpers.test.js b/server/api-util/lineItemHelpers.test.js new file mode 100644 index 000000000..e98af4093 --- /dev/null +++ b/server/api-util/lineItemHelpers.test.js @@ -0,0 +1,263 @@ +const { types } = require('sharetribe-flex-sdk'); +const { Money } = types; + +const { + calculateTotalPriceFromQuantity, + calculateTotalPriceFromPercentage, + calculateTotalPriceFromSeats, + calculateQuantityFromDates, + calculateLineTotal, + calculateTotalFromLineItems, + calculateTotalForProvider, + calculateTotalForCustomer, + constructValidLineItems, +} = require('./lineItemHelpers'); + +describe('calculateTotalPriceFromQuantity()', () => { + it('should calculate price based on quantity', () => { + const unitPrice = new Money(1000, 'EUR'); + const quantity = 3; + expect(calculateTotalPriceFromQuantity(unitPrice, quantity)).toEqual(new Money(3000, 'EUR')); + }); +}); + +describe('calculateTotalPriceFromPercentage()', () => { + it('should calculate price based on percentage', () => { + const unitPrice = new Money(1000, 'EUR'); + const percentage = 10; + expect(calculateTotalPriceFromPercentage(unitPrice, percentage)).toEqual(new Money(100, 'EUR')); + }); + + it('should return negative sum if percentage is negative', () => { + const unitPrice = new Money(1000, 'EUR'); + const percentage = -10; + expect(calculateTotalPriceFromPercentage(unitPrice, percentage)).toEqual( + new Money(-100, 'EUR') + ); + }); +}); + +describe('calculateTotalPriceFromSeats()', () => { + it('should calculate price based on seats and units', () => { + const unitPrice = new Money(1000, 'EUR'); + const unitCount = 1; + const seats = 3; + expect(calculateTotalPriceFromSeats(unitPrice, unitCount, seats)).toEqual( + new Money(3000, 'EUR') + ); + }); + + it('should throw error if value of seats is negative', () => { + const unitPrice = new Money(1000, 'EUR'); + const unitCount = 1; + const seats = -3; + expect(() => calculateTotalPriceFromSeats(unitPrice, unitCount, seats)).toThrowError( + "Value of seats can't be negative" + ); + }); +}); + +describe('calculateQuantityFromDates()', () => { + it('should calculate quantity based on given dates with nightly bookings', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 3); + const type = 'line-item/night'; + expect(calculateQuantityFromDates(start, end, type)).toEqual(2); + }); + + it('should calculate quantity based on given dates with daily bookings', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 3); + const type = 'line-item/day'; + expect(calculateQuantityFromDates(start, end, type)).toEqual(2); + }); + + it('should throw error if unit type is not night or day', () => { + const start = new Date(2017, 0, 1); + const end = new Date(2017, 0, 3); + const type = 'line-item/units'; + expect(() => calculateQuantityFromDates(start, end, type)).toThrowError( + `Can't calculate quantity from dates to unit type: ${type}` + ); + }); +}); + +describe('calculateLineTotal()', () => { + it('should calculate lineTotal for lineItem with quantity', () => { + const lineItem = { + code: 'line-item/cleaning-fee', + unitPrice: new Money(1000, 'EUR'), + quantity: 3, + includeFor: ['customer', 'provider'], + }; + expect(calculateLineTotal(lineItem)).toEqual(new Money(3000, 'EUR')); + }); + + it('should calculate lineTotal for lineItem with percentage', () => { + const lineItem = { + code: 'line-item/customer-commission', + unitPrice: new Money(3000, 'EUR'), + percentage: 10, + includeFor: ['customer', 'provider'], + }; + expect(calculateLineTotal(lineItem)).toEqual(new Money(300, 'EUR')); + }); + + it('should calculate lineTotal for lineItem with seats and units', () => { + const lineItem = { + code: 'line-item/customer-fee', + unitPrice: new Money(1000, 'EUR'), + seats: 4, + units: 2, + includeFor: ['customer', 'provider'], + }; + expect(calculateLineTotal(lineItem)).toEqual(new Money(8000, 'EUR')); + }); + + it('should throw error if no pricing params are found', () => { + const lineItem = { + code: 'line-item/customer-fee', + unitPrice: new Money(1000, 'EUR'), + includeFor: ['customer', 'provider'], + }; + const code = lineItem.code; + expect(() => calculateLineTotal(lineItem)).toThrowError( + `Can't calculate the lineTotal of lineItem: ${code}. Make sure the lineItem has quantity, percentage or both seats and units` + ); + }); +}); + +describe('calculateTotalFromLineItems()', () => { + it('should calculate total of given lineItems lineTotals', () => { + const lineItems = [ + { + code: 'line-item/nights', + unitPrice: new Money(10000, 'USD'), + quantity: 3, + includeFor: ['customer', 'provider'], + }, + + { + code: 'line-item/cleaning-fee', + unitPrice: new Money(5000, 'USD'), + quantity: 1, + includeFor: ['customer', 'provider'], + }, + ]; + expect(calculateTotalFromLineItems(lineItems)).toEqual(new Money(35000, 'USD')); + }); +}); + +describe('calculateTotalForProvider()', () => { + it('should calculate total of lineItems where includeFor includes provider', () => { + const lineItems = [ + { + code: 'line-item/nights', + unitPrice: new Money(5000, 'USD'), + units: 3, + seats: 2, + includeFor: ['customer', 'provider'], + }, + { + code: 'line-item/cleaning-fee', + unitPrice: new Money(7500, 'USD'), + quantity: 1, + lineTotal: new Money(7500, 'USD'), + includeFor: ['customer', 'provider'], + }, + { + code: 'line-item/customer-commission', + unitPrice: new Money(30000, 'USD'), + percentage: 10, + includeFor: ['customer'], + }, + { + code: 'line-item/provider-commission', + unitPrice: new Money(30000, 'USD'), + percentage: -10, + includeFor: ['provider'], + }, + { + code: 'line-item/provider-commission-discount', + unitPrice: new Money(2500, 'USD'), + quantity: 1, + lineTotal: new Money(2500, 'USD'), + includeFor: ['provider'], + }, + ]; + expect(calculateTotalForProvider(lineItems)).toEqual(new Money(37000, 'USD')); + }); +}); + +describe('calculateTotalForCustomer()', () => { + it('should calculate total of lineItems where includeFor includes customer', () => { + const lineItems = [ + { + code: 'line-item/nights', + unitPrice: new Money(5000, 'USD'), + units: 3, + seats: 2, + includeFor: ['customer', 'provider'], + }, + { + code: 'line-item/cleaning-fee', + unitPrice: new Money(7500, 'USD'), + quantity: 1, + lineTotal: new Money(7500, 'USD'), + includeFor: ['customer', 'provider'], + }, + { + code: 'line-item/customer-commission', + unitPrice: new Money(30000, 'USD'), + percentage: 10, + includeFor: ['customer'], + }, + { + code: 'line-item/provider-commission', + unitPrice: new Money(30000, 'USD'), + percentage: -10, + includeFor: ['provider'], + }, + { + code: 'line-item/provider-commission-discount', + unitPrice: new Money(2500, 'USD'), + quantity: 1, + lineTotal: new Money(2500, 'USD'), + includeFor: ['provider'], + }, + ]; + expect(calculateTotalForCustomer(lineItems)).toEqual(new Money(40500, 'USD')); + }); +}); + +describe('constructValidLineItems()', () => { + it('should add lineTotal and reversal attributes to the lineItem', () => { + const lineItems = [ + { + code: 'line-item/nights', + unitPrice: new Money(5000, 'USD'), + quantity: 2, + includeFor: ['customer', 'provider'], + }, + ]; + expect(constructValidLineItems(lineItems)[0].lineTotal).toEqual(new Money(10000, 'USD')); + expect(constructValidLineItems(lineItems)[0].reversal).toEqual(false); + expect(constructValidLineItems(lineItems)[0].reversal).not.toBeUndefined(); + }); + + it('should throw error if lineItem code is not valid', () => { + const code = 'nights'; + const lineItems = [ + { + code, + unitPrice: new Money(5000, 'USD'), + quantity: 2, + includeFor: ['customer', 'provider'], + }, + ]; + + expect(() => constructValidLineItems(lineItems)).toThrowError( + `Invalid line item code: ${code}` + ); + }); +}); From 9b99e14d742a07eff7d13bd204aef27aab17b41c Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 2 Jun 2020 16:21:47 +0300 Subject: [PATCH 05/41] Add test-server command to package.json to run server side tests --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a427c38a2..091a49c45 100644 --- a/package.json +++ b/package.json @@ -94,10 +94,11 @@ "format-ci": "prettier --list-different '**/*.{js,css}'", "format-docs": "prettier --write '**/*.md'", "test": "NODE_ICU_DATA=node_modules/full-icu sharetribe-scripts test", - "test-ci": "sharetribe-scripts test --runInBand", + "test-ci": "yarn run test-server --runInBand && sharetribe-scripts test --runInBand", "eject": "sharetribe-scripts eject", "start": "node --icu-data-dir=node_modules/full-icu server/index.js", "dev-server": "export NODE_ENV=development PORT=4000 REACT_APP_CANONICAL_ROOT_URL=http://localhost:4000&&yarn run build&&nodemon --watch server server/index.js", + "test-server": "jest ./server/**/*.test.js", "heroku-postbuild": "yarn run build", "translate": "node scripts/translations.js" }, From ce1e71f7ca7413e3807bef5af80b68e4d3ad8bcb Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Thu, 4 Jun 2020 10:42:59 +0300 Subject: [PATCH 06/41] Fix test in src/util/currency.test.js --- src/util/currency.js | 4 +++- src/util/currency.test.js | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/util/currency.js b/src/util/currency.js index 7dc45a9cb..35ebb0b7a 100644 --- a/src/util/currency.js +++ b/src/util/currency.js @@ -22,7 +22,9 @@ export const isSafeNumber = decimalValue => { // Get the minor unit divisor for the given currency export const unitDivisor = currency => { if (!has(subUnitDivisors, currency)) { - throw new Error(`No minor unit divisor defined for currency: ${currency}`); + throw new Error( + `No minor unit divisor defined for currency: ${currency} in currency-config.js` + ); } return subUnitDivisors[currency]; }; diff --git a/src/util/currency.test.js b/src/util/currency.test.js index 50c00a1ed..7fc05d620 100644 --- a/src/util/currency.test.js +++ b/src/util/currency.test.js @@ -215,10 +215,10 @@ describe('currency utils', () => { describe('convertMoneyToNumber(value)', () => { it('Money as value', () => { - expect(convertMoneyToNumber(new Money(10, 'USD'))).toBeCloseTo(0.1); - expect(convertMoneyToNumber(new Money(1000, 'USD'))).toBeCloseTo(10); - expect(convertMoneyToNumber(new Money(9900, 'USD'))).toBeCloseTo(99); - expect(convertMoneyToNumber(new Money(10099, 'USD'))).toBeCloseTo(100.99); + 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); }); it('Wrong type of a parameter', () => { From 52870a079f9c0cb62407612d9722239d90c0a239 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Fri, 5 Jun 2020 11:48:03 +0300 Subject: [PATCH 07/41] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c67d4600..f3d3400fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [add] Add util functions for constructing line items in FTW backend + [#1299](https://github.com/sharetribe/ftw-daily/pull/1299) - [fix] `yarn run dev-backend` was expecting NODE_ENV. [#1303](https://github.com/sharetribe/ftw-daily/pull/1303) From d97155c9622e74b57823da8aa9207c5e93a0c0c5 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 10 Jun 2020 14:55:27 +0300 Subject: [PATCH 08/41] Add local endpoints for tx line items and tx initiate --- package.json | 7 +- server/api-util/sdk.js | 122 +++++++++++++++++++++++++++ server/api/initiate-privileged.js | 54 ++++++++++++ server/api/transaction-line-items.js | 26 ++++++ server/apiRouter.js | 34 ++++++++ server/apiServer.js | 14 ++- src/util/api.js | 73 ++++++++++++++++ src/util/sdkLoader.js | 4 +- yarn.lock | 20 +++-- 9 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 server/api-util/sdk.js create mode 100644 server/api/initiate-privileged.js create mode 100644 server/api/transaction-line-items.js create mode 100644 src/util/api.js diff --git a/package.json b/package.json index 091a49c45..a949f5023 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.5", "core-js": "^3.1.4", + "cors": "^2.8.5", "decimal.js": "10.2.0", "dotenv": "6.2.0", "dotenv-expand": "4.2.0", @@ -51,7 +52,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "seedrandom": "^3.0.5", - "sharetribe-flex-sdk": "^1.9.1", + "sharetribe-flex-sdk": "1.11.0-alpha", "sharetribe-scripts": "3.1.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.9", @@ -87,8 +88,8 @@ "config": "node scripts/config.js", "config-check": "node scripts/config.js --check", "dev-frontend": "sharetribe-scripts start", - "dev-backend": "export NODE_ENV=development DEV_API_SERVER_PORT=3500&&nodemon server/apiServer.js", - "dev": "yarn run config-check&&concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", + "dev-backend": "nodemon server/apiServer.js", + "dev": "yarn run config-check&&export NODE_ENV=development REACT_APP_DEV_API_SERVER_PORT=3500&&concurrently --kill-others \"yarn run dev-frontend\" \"yarn run dev-backend\"", "build": "sharetribe-scripts build", "format": "prettier --write '**/*.{js,css}'", "format-ci": "prettier --list-different '**/*.{js,css}'", diff --git a/server/api-util/sdk.js b/server/api-util/sdk.js new file mode 100644 index 000000000..c657b636b --- /dev/null +++ b/server/api-util/sdk.js @@ -0,0 +1,122 @@ +const http = require('http'); +const https = require('https'); +const Decimal = require('decimal.js'); +const sharetribeSdk = require('sharetribe-flex-sdk'); + +const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; +const CLIENT_SECRET = process.env.SHARETRIBE_SDK_CLIENT_SECRET; +const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; +const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true'; + +const typeHandlers = [ + { + type: sharetribeSdk.types.BigDecimal, + customType: Decimal, + writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), + reader: v => new Decimal(v.value), + }, +]; +const baseUrlMaybe = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL + ? { baseUrl: process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL } + : null; +const httpAgent = new http.Agent({ keepAlive: true }); +const httpsAgent = new https.Agent({ keepAlive: true }); + +const memoryStore = token => { + const store = sharetribeSdk.tokenStore.memoryStore(); + store.setToken(token); + return store; +}; + +// Read the user token from the request cookie +const getUserToken = req => { + const cookieTokenStore = sharetribeSdk.tokenStore.expressCookieStore({ + clientId: CLIENT_ID, + req, + secure: USING_SSL, + }); + return cookieTokenStore.getToken(); +}; + +exports.serialize = data => { + return sharetribeSdk.transit.write(data, { typeHandlers, verbose: TRANSIT_VERBOSE }); +}; + +exports.deserialize = str => { + return sharetribeSdk.transit.read(str, { typeHandlers }); +}; + +exports.handleError = (res, error) => { + console.error(error); + if (error.status && error.statusText && error.data) { + // JS SDK error + res + .status(error.status) + .json({ + status: error.status, + statusText: error.statusText, + data: error.data, + }) + .end(); + } else { + res + .status(500) + .json({ error: error.message }) + .end(); + } +}; + +exports.getSdk = (req, res) => { + return sharetribeSdk.createInstance({ + transitVerbose: TRANSIT_VERBOSE, + clientId: CLIENT_ID, + httpAgent, + httpsAgent, + tokenStore: sharetribeSdk.tokenStore.expressCookieStore({ + clientId: CLIENT_ID, + req, + res, + secure: USING_SSL, + }), + typeHandlers, + ...baseUrlMaybe, + }); +}; + +exports.getTrustedSdk = req => { + const userToken = getUserToken(req); + + // Initiate an SDK instance for token exchange + const sdk = sharetribeSdk.createInstance({ + transitVerbose: TRANSIT_VERBOSE, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + httpAgent, + httpsAgent, + tokenStore: memoryStore(userToken), + typeHandlers, + ...baseUrlMaybe, + }); + + // Perform a token exchange + return sdk.exchangeToken().then(response => { + // Setup a trusted sdk with the token we got from the exchange: + const trustedToken = response.data; + + return sharetribeSdk.createInstance({ + transitVerbose: TRANSIT_VERBOSE, + + // We don't need CLIENT_SECRET here anymore + clientId: CLIENT_ID, + + // Important! Do not use a cookieTokenStore here but a memoryStore + // instead so that we don't leak the token back to browser client. + tokenStore: memoryStore(trustedToken), + + httpAgent, + httpsAgent, + typeHandlers, + ...baseUrlMaybe, + }); + }); +}; diff --git a/server/api/initiate-privileged.js b/server/api/initiate-privileged.js new file mode 100644 index 000000000..945ac4492 --- /dev/null +++ b/server/api/initiate-privileged.js @@ -0,0 +1,54 @@ +const { transactionLineItems } = require('../api-util/lineItems'); +const { getSdk, getTrustedSdk, handleError, serialize, deserialize } = require('../api-util/sdk'); + +module.exports = (req, res) => { + const { isSpeculative, bookingData, bodyParams, queryParams } = req.body; + + const listingId = bodyParams && bodyParams.params ? bodyParams.params.listingId : null; + + const sdk = getSdk(req, res); + let lineItems = null; + + sdk.listings + .show({ id: listingId }) + .then(listingResponse => { + const listing = listingResponse.data.data; + lineItems = transactionLineItems(listing, bookingData); + + return getTrustedSdk(req); + }) + .then(trustedSdk => { + const { params } = bodyParams; + + // Add lineItems to the body params + const body = { + ...bodyParams, + params: { + ...params, + lineItems, + }, + }; + + if (isSpeculative) { + return trustedSdk.transactions.initiateSpeculative(body, queryParams); + } + return trustedSdk.transactions.initiate(body, queryParams); + }) + .then(apiResponse => { + const { status, statusText, data } = apiResponse; + res + .status(status) + .set('Content-Type', 'application/transit+json') + .send( + serialize({ + status, + statusText, + data, + }) + ) + .end(); + }) + .catch(e => { + handleError(res, e); + }); +}; diff --git a/server/api/transaction-line-items.js b/server/api/transaction-line-items.js new file mode 100644 index 000000000..fe3e1fb61 --- /dev/null +++ b/server/api/transaction-line-items.js @@ -0,0 +1,26 @@ +const { transactionLineItems } = require('../api-util/lineItems'); +const { getSdk, handleError, serialize } = require('../api-util/sdk'); + +module.exports = (req, res) => { + const { isOwnListing, listingId, bookingData } = req.body; + + const sdk = getSdk(req, res); + + const listingPromise = isOwnListing + ? sdk.ownListings.show({ id: listingId }) + : sdk.listings.show({ id: listingId }); + + listingPromise + .then(apiResponse => { + const listing = apiResponse.data.data; + const lineItems = transactionLineItems(listing, bookingData); + res + .status(200) + .set('Content-Type', 'application/transit+json') + .send(serialize({ data: lineItems })) + .end(); + }) + .catch(e => { + handleError(res, e); + }); +}; diff --git a/server/apiRouter.js b/server/apiRouter.js index 60c2b9e3a..b47fe99ee 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -7,13 +7,47 @@ */ const express = require('express'); +const bodyParser = require('body-parser'); +const { deserialize } = require('./api-util/sdk'); const initiateLoginAs = require('./api/initiate-login-as'); const loginAs = require('./api/login-as'); +const transactionLineItems = require('./api/transaction-line-items'); +const initiatePrivileged = require('./api/initiate-privileged'); const router = express.Router(); +// ================ API router middleware: ================ // + +// Parse Transit body first to a string +router.use( + bodyParser.text({ + type: 'application/transit+json', + }) +); + +// Deserialize Transit body string to JS data +router.use((req, res, next) => { + if (req.get('Content-Type') === 'application/transit+json' && typeof req.body === 'string') { + try { + req.body = deserialize(req.body); + } catch (e) { + console.error('Failed to parse request body as Transit:'); + console.error(e); + res.status(400).send('Invalid Transit in request body.'); + return; + } + } else { + console.log('request not Transit'); + } + next(); +}); + +// ================ API router endpoints: ================ // + router.get('/initiate-login-as', initiateLoginAs); router.get('/login-as', loginAs); +router.post('/transaction-line-items', transactionLineItems); +router.post('/initiate-privileged', initiatePrivileged); module.exports = router; diff --git a/server/apiServer.js b/server/apiServer.js index 6488a115d..6943df591 100644 --- a/server/apiServer.js +++ b/server/apiServer.js @@ -6,13 +6,23 @@ require('./env').configureEnv(); const express = require('express'); const cookieParser = require('cookie-parser'); +const bodyParser = require('body-parser'); +const cors = require('cors'); const apiRouter = require('./apiRouter'); const radix = 10; -const PORT = parseInt(process.env.DEV_API_SERVER_PORT, radix); +const PORT = parseInt(process.env.REACT_APP_DEV_API_SERVER_PORT, radix); const app = express(); -app.use(cookieParser()); +// NOTE: CORS is only needed in this dev API server because it's +// running in a different port than the main app. +app.use( + cors({ + origin: process.env.REACT_APP_CANONICAL_ROOT_URL, + credentials: true, + }) +); +app.use(cookieParser()); app.use('/api', apiRouter); app.listen(PORT, () => { diff --git a/src/util/api.js b/src/util/api.js new file mode 100644 index 000000000..7931296d9 --- /dev/null +++ b/src/util/api.js @@ -0,0 +1,73 @@ +import { types as sdkTypes, transit } from './sdkLoader'; +import moment from 'moment'; +import Decimal from 'decimal.js'; + +const apiBaseUrl = () => { + const port = process.env.REACT_APP_DEV_API_SERVER_PORT; + const useDevApiServer = process.env.NODE_ENV === 'development' && !!port; + + // In development, the dev API server is running in a different port + if (useDevApiServer) { + return `http://localhost:${port}`; + } + + // Otherwise, use the same domain and port as the frontend + return '/'; +}; + +const typeHandlers = [ + { + type: sdkTypes.BigDecimal, + customType: Decimal, + writer: v => new sdkTypes.BigDecimal(v.toString()), + reader: v => new Decimal(v.value), + }, +]; + +const serialize = data => { + return transit.write(data, { typeHandlers }); +}; + +const deserialize = str => { + return transit.read(str, { typeHandlers }); +}; + +const post = (path, body) => { + const url = `${apiBaseUrl()}${path}`; + const options = { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/transit+json', + }, + body: serialize(body), + }; + return window + .fetch(url, options) + .then(res => { + if (res.status >= 400) { + const e = new Error('Local API request failed'); + e.apiResponse = res; + throw e; + } + return res; + }) + .then(res => { + const contentTypeHeader = res.headers.get('Content-Type'); + const contentType = contentTypeHeader ? contentTypeHeader.split(';')[0] : null; + if (contentType === 'application/transit+json') { + return res.text().then(deserialize); + } else if (contentType === 'application/json') { + return res.json(); + } + return res.text(); + }); +}; + +export const transactionLineItems = body => { + return post('/api/transaction-line-items', body); +}; + +export const initiatePrivileged = body => { + return post('/api/initiate-privileged', body); +}; diff --git a/src/util/sdkLoader.js b/src/util/sdkLoader.js index 273e4a43b..4e35c52de 100644 --- a/src/util/sdkLoader.js +++ b/src/util/sdkLoader.js @@ -12,6 +12,6 @@ if (isServer()) { exportSdk = importedSdk; } -const { createInstance, types } = exportSdk; +const { createInstance, types, transit } = exportSdk; -export { createInstance, types }; +export { createInstance, types, transit }; diff --git a/yarn.lock b/yarn.lock index 24cef0acb..97f2052ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3069,6 +3069,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@^5.0.0, cosmiconfig@^5.2.0, cosmiconfig@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" @@ -7665,7 +7673,7 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== -object-assign@4.1.1, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.1.1, object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -10231,10 +10239,10 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -sharetribe-flex-sdk@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.9.1.tgz#a73f4cbe1ecc11de014a80724a55a43f7e57bfa6" - integrity sha512-KDi0u5dHpUHmdbUH940oDWdLhtcD+OBs3RVnILZsv/HFWhtJuwEZxqjJ9h1SwF1L1aZ7hlgWhoae6Md4fe75WQ== +sharetribe-flex-sdk@1.11.0-alpha: + version "1.11.0-alpha" + resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.11.0-alpha.tgz#cd48783061d875d52db7fbe9bf228b40c55ae62f" + integrity sha512-zZn/8vEHq/NQVFd0qxmvVBW/TSdBCWTkfXaz69VjRi0MlbcKGlFLiN/CyG/Vsp+2GBdlkboWV0bvWJdUTJUO0g== dependencies: axios "^0.19.0" js-cookie "^2.1.3" @@ -11417,7 +11425,7 @@ value-equal@^0.4.0: resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" integrity sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw== -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= From 9836416ce31989a67ee684e340c037ec338d9cfc Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 11 Jun 2020 09:11:57 +0300 Subject: [PATCH 09/41] Use the released version of JS SDK --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a949f5023..165f04bb4 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "seedrandom": "^3.0.5", - "sharetribe-flex-sdk": "1.11.0-alpha", + "sharetribe-flex-sdk": "1.11.0-alpha-1", "sharetribe-scripts": "3.1.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.9", diff --git a/yarn.lock b/yarn.lock index 97f2052ba..33d1cea6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10239,10 +10239,10 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -sharetribe-flex-sdk@1.11.0-alpha: - version "1.11.0-alpha" - resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.11.0-alpha.tgz#cd48783061d875d52db7fbe9bf228b40c55ae62f" - integrity sha512-zZn/8vEHq/NQVFd0qxmvVBW/TSdBCWTkfXaz69VjRi0MlbcKGlFLiN/CyG/Vsp+2GBdlkboWV0bvWJdUTJUO0g== +sharetribe-flex-sdk@1.11.0-alpha-1: + version "1.11.0-alpha-1" + resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.11.0-alpha-1.tgz#99c0f8fb31a29d10b0a7247d8d4a40e22d0b70de" + integrity sha512-88vaF6Jjk80gHxkK84Fa/8AXsUC2x+I3PyKsKXZxfVWMLe3AqL9UHhi/3h7U9JEyFc9C1XGiQD0oqiK7EDihwA== dependencies: axios "^0.19.0" js-cookie "^2.1.3" From 5f54e7313fc339dcb691a0f8a9f769788f153de4 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 11 Jun 2020 11:06:05 +0300 Subject: [PATCH 10/41] Remove unused import. --- src/util/api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/util/api.js b/src/util/api.js index 7931296d9..e3e124a85 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -1,5 +1,4 @@ import { types as sdkTypes, transit } from './sdkLoader'; -import moment from 'moment'; import Decimal from 'decimal.js'; const apiBaseUrl = () => { From 3f1e1c2964c281336d4324b6e05f35d5fbaa12cf Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 11 Jun 2020 14:28:38 +0300 Subject: [PATCH 11/41] Unify SDK type handlers for frontend and backend --- server/api-util/sdk.js | 6 ++++++ server/api/login-as.js | 10 ++-------- server/index.js | 10 ++-------- src/index.js | 10 ++-------- src/util/api.js | 6 +++++- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/server/api-util/sdk.js b/server/api-util/sdk.js index c657b636b..f2d6ff185 100644 --- a/server/api-util/sdk.js +++ b/server/api-util/sdk.js @@ -8,7 +8,11 @@ const CLIENT_SECRET = process.env.SHARETRIBE_SDK_CLIENT_SECRET; const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true'; +// Application type handlers for JS SDK. +// +// NOTE: keep in sync with `typeHandlers` in `src/util/api.js` const typeHandlers = [ + // Use Decimal type instead of SDK's BigDecimal. { type: sharetribeSdk.types.BigDecimal, customType: Decimal, @@ -16,6 +20,8 @@ const typeHandlers = [ reader: v => new Decimal(v.value), }, ]; +exports.typeHandlers = typeHandlers; + const baseUrlMaybe = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL ? { baseUrl: process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL } : null; diff --git a/server/api/login-as.js b/server/api/login-as.js index b8c40b79b..9a5272ba2 100644 --- a/server/api/login-as.js +++ b/server/api/login-as.js @@ -2,6 +2,7 @@ const http = require('http'); const https = require('https'); const sharetribeSdk = require('sharetribe-flex-sdk'); const Decimal = require('decimal.js'); +const sdkUtils = require('../api-util/sdk'); const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL; @@ -71,14 +72,7 @@ module.exports = (req, res) => { httpAgent: httpAgent, httpsAgent: httpsAgent, tokenStore, - typeHandlers: [ - { - type: sharetribeSdk.types.BigDecimal, - customType: Decimal, - writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), - reader: v => new Decimal(v.value), - }, - ], + typeHandlers: sdkUtils.typeHandlers, ...baseUrl, }); diff --git a/server/index.js b/server/index.js index bafed3384..bc72c3c3e 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,7 @@ const fs = require('fs'); const log = require('./log'); const { sitemapStructure } = require('./sitemap'); const csp = require('./csp'); +const sdkUtils = require('./api-util/sdk'); const buildPath = path.resolve(__dirname, '..', 'build'); const env = process.env.REACT_APP_ENV; @@ -179,14 +180,7 @@ app.get('*', (req, res) => { httpAgent: httpAgent, httpsAgent: httpsAgent, tokenStore, - typeHandlers: [ - { - type: sharetribeSdk.types.BigDecimal, - customType: Decimal, - writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), - reader: v => new Decimal(v.value), - }, - ], + typeHandlers: sdkUtils.typeHandlers, ...baseUrl, }); diff --git a/src/index.js b/src/index.js index 77975539b..12aa66888 100644 --- a/src/index.js +++ b/src/index.js @@ -25,6 +25,7 @@ import { ClientApp, renderApp } from './app'; import configureStore from './store'; import { matchPathname } from './util/routes'; import * as sample from './util/sample'; +import * as apiUtils from './util/api'; import config from './config'; import { authInfo } from './ducks/Auth.duck'; import { fetchCurrentUser } from './ducks/user.duck'; @@ -86,14 +87,7 @@ if (typeof window !== 'undefined') { transitVerbose: config.sdk.transitVerbose, clientId: config.sdk.clientId, secure: config.usingSSL, - typeHandlers: [ - { - type: BigDecimal, - customType: Decimal, - writer: v => new BigDecimal(v.toString()), - reader: v => new Decimal(v.value), - }, - ], + typeHandlers: apiUtils.typeHandlers, ...baseUrl, }); const analyticsHandlers = setupAnalyticsHandlers(); diff --git a/src/util/api.js b/src/util/api.js index e3e124a85..96704768b 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -14,7 +14,11 @@ const apiBaseUrl = () => { return '/'; }; -const typeHandlers = [ +// Application type handlers for JS SDK. +// +// NOTE: keep in sync with `typeHandlers` in `server/api-util/sdk.js` +export const typeHandlers = [ + // Use Decimal type instead of SDK's BigDecimal. { type: sdkTypes.BigDecimal, customType: Decimal, From 80e6346b49435d700f55e85397cca7115ed33bff Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 11 Jun 2020 15:32:13 +0300 Subject: [PATCH 12/41] Document API endpoint wrappers --- src/util/api.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/util/api.js b/src/util/api.js index 96704768b..9ac65a5dc 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -67,10 +67,22 @@ const post = (path, body) => { }); }; +// Fetch transaction line items from the local API endpoint. +// +// See `server/api/transaction-line-items.js` to see what data should +// be sent in the body. export const transactionLineItems = body => { return post('/api/transaction-line-items', body); }; +// Initiate a privileged transaction. +// +// With privileged transitions, the transactions need to be created +// from the backend. This endpoint enables sending the booking data to +// the local backend, and passing that to the Marketplace API. +// +// See `server/api/initiate-privileged.js` to see what data should be +// sent in the body. export const initiatePrivileged = body => { return post('/api/initiate-privileged', body); }; From 65c7c556d32a301a55811602465c8f56338fc57a Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Fri, 12 Jun 2020 10:05:18 +0300 Subject: [PATCH 13/41] Remove unused imports --- server/index.js | 1 - src/index.js | 3 --- 2 files changed, 4 deletions(-) diff --git a/server/index.js b/server/index.js index bc72c3c3e..59882ebc4 100644 --- a/server/index.js +++ b/server/index.js @@ -29,7 +29,6 @@ const bodyParser = require('body-parser'); const enforceSsl = require('express-enforces-ssl'); const path = require('path'); const sharetribeSdk = require('sharetribe-flex-sdk'); -const Decimal = require('decimal.js'); const sitemap = require('express-sitemap'); const auth = require('./auth'); const apiRouter = require('./apiRouter'); diff --git a/src/index.js b/src/index.js index 12aa66888..2b04be14a 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,6 @@ import 'raf/polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; -import Decimal from 'decimal.js'; import { createInstance, types as sdkTypes } from './util/sdkLoader'; import { ClientApp, renderApp } from './app'; import configureStore from './store'; @@ -35,8 +34,6 @@ import { LoggingAnalyticsHandler, GoogleAnalyticsHandler } from './analytics/han import './marketplaceIndex.css'; -const { BigDecimal } = sdkTypes; - const render = (store, shouldHydrate) => { // If the server already loaded the auth information, render the app // immediately. Otherwise wait for the flag to be loaded and render From cc0d3226e4f259e298b4afd8458cc430866f8bb2 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Fri, 12 Jun 2020 10:33:50 +0300 Subject: [PATCH 14/41] Fix local API base URL for production server --- src/util/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/api.js b/src/util/api.js index 9ac65a5dc..8f3399899 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -11,7 +11,7 @@ const apiBaseUrl = () => { } // Otherwise, use the same domain and port as the frontend - return '/'; + return `${window.location.origin}`; }; // Application type handlers for JS SDK. From c6594dbe30c5e4220c3928e47d3faa33055efd49 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Fri, 12 Jun 2020 17:18:42 +0300 Subject: [PATCH 15/41] Privileged transition endpoint --- server/api/initiate-privileged.js | 2 +- server/api/transition-privileged.js | 52 +++++++++++++++++++++++++++++ server/apiRouter.js | 2 ++ src/util/api.js | 12 +++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 server/api/transition-privileged.js diff --git a/server/api/initiate-privileged.js b/server/api/initiate-privileged.js index 945ac4492..4aaea5398 100644 --- a/server/api/initiate-privileged.js +++ b/server/api/initiate-privileged.js @@ -1,5 +1,5 @@ const { transactionLineItems } = require('../api-util/lineItems'); -const { getSdk, getTrustedSdk, handleError, serialize, deserialize } = require('../api-util/sdk'); +const { getSdk, getTrustedSdk, handleError, serialize } = require('../api-util/sdk'); module.exports = (req, res) => { const { isSpeculative, bookingData, bodyParams, queryParams } = req.body; diff --git a/server/api/transition-privileged.js b/server/api/transition-privileged.js new file mode 100644 index 000000000..74910dfbc --- /dev/null +++ b/server/api/transition-privileged.js @@ -0,0 +1,52 @@ +const { transactionLineItems } = require('../api-util/lineItems'); +const { getSdk, getTrustedSdk, handleError, serialize } = require('../api-util/sdk'); + +module.exports = (req, res) => { + const { isSpeculative, bookingData, bodyParams, queryParams } = req.body; + + const { listingId, ...restParams } = bodyParams && bodyParams.params ? bodyParams.params : {}; + + const sdk = getSdk(req, res); + let lineItems = null; + + sdk.listings + .show({ id: listingId }) + .then(listingResponse => { + const listing = listingResponse.data.data; + lineItems = transactionLineItems(listing, bookingData); + + return getTrustedSdk(req); + }) + .then(trustedSdk => { + // Add lineItems to the body params + const body = { + ...bodyParams, + params: { + ...restParams, + lineItems, + }, + }; + + if (isSpeculative) { + return trustedSdk.transactions.transitionSpeculative(body, queryParams); + } + return trustedSdk.transactions.transition(body, queryParams); + }) + .then(apiResponse => { + const { status, statusText, data } = apiResponse; + res + .status(status) + .set('Content-Type', 'application/transit+json') + .send( + serialize({ + status, + statusText, + data, + }) + ) + .end(); + }) + .catch(e => { + handleError(res, e); + }); +}; diff --git a/server/apiRouter.js b/server/apiRouter.js index b47fe99ee..8cbe13f1b 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -14,6 +14,7 @@ const initiateLoginAs = require('./api/initiate-login-as'); const loginAs = require('./api/login-as'); const transactionLineItems = require('./api/transaction-line-items'); const initiatePrivileged = require('./api/initiate-privileged'); +const transitionPrivileged = require('./api/transition-privileged'); const router = express.Router(); @@ -49,5 +50,6 @@ router.get('/initiate-login-as', initiateLoginAs); router.get('/login-as', loginAs); router.post('/transaction-line-items', transactionLineItems); router.post('/initiate-privileged', initiatePrivileged); +router.post('/transition-privileged', transitionPrivileged); module.exports = router; diff --git a/src/util/api.js b/src/util/api.js index 8f3399899..95e01e334 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -86,3 +86,15 @@ export const transactionLineItems = body => { export const initiatePrivileged = body => { return post('/api/initiate-privileged', body); }; + +// Transition a transaction with a privileged transition. +// +// This is similar to the `initiatePrivileged` above. It will use the +// backend for the transition. The backend endpoint will add the +// payment line items to the transition params. +// +// See `server/api/transition-privileged.js` to see what data should +// be sent in the body. +export const transitionPrivileged = body => { + return post('/api/transition-privileged', body); +}; From 6e32d77f2df5d1fa822fc87ec0d9a92deb9c3f3d Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 16 Jun 2020 11:43:51 +0300 Subject: [PATCH 16/41] Bump up SDK version to the non-alpha one --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 165f04bb4..df6f22001 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "seedrandom": "^3.0.5", - "sharetribe-flex-sdk": "1.11.0-alpha-1", + "sharetribe-flex-sdk": "1.11.0", "sharetribe-scripts": "3.1.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.9", diff --git a/yarn.lock b/yarn.lock index 33d1cea6f..9db5c84bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10239,10 +10239,10 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -sharetribe-flex-sdk@1.11.0-alpha-1: - version "1.11.0-alpha-1" - resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.11.0-alpha-1.tgz#99c0f8fb31a29d10b0a7247d8d4a40e22d0b70de" - integrity sha512-88vaF6Jjk80gHxkK84Fa/8AXsUC2x+I3PyKsKXZxfVWMLe3AqL9UHhi/3h7U9JEyFc9C1XGiQD0oqiK7EDihwA== +sharetribe-flex-sdk@1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/sharetribe-flex-sdk/-/sharetribe-flex-sdk-1.11.0.tgz#31897c74482dd3de85fac9f71f4e65a8fc84c9a5" + integrity sha512-zp/byvmq8kbc0AsydT+jcqSqGtTuyPw8/e22koqnAU509IRg0S6UQrC0SMNYMY8XzimZnNpSvBT8sitt2WVrjw== dependencies: axios "^0.19.0" js-cookie "^2.1.3" From a4f9432baf7c8ed8f767d981c23d003f1e30a1ec Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 16 Jun 2020 12:46:34 +0300 Subject: [PATCH 17/41] Remove extra console.log --- server/apiRouter.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/apiRouter.js b/server/apiRouter.js index 8cbe13f1b..1a09735cb 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -38,8 +38,6 @@ router.use((req, res, next) => { res.status(400).send('Invalid Transit in request body.'); return; } - } else { - console.log('request not Transit'); } next(); }); From 480b77b5190424bd60dc023ed8e308a259b5fc29 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 16 Jun 2020 12:46:46 +0300 Subject: [PATCH 18/41] Add verbose flag to serialization --- src/util/api.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/api.js b/src/util/api.js index 95e01e334..8a2af15fd 100644 --- a/src/util/api.js +++ b/src/util/api.js @@ -1,4 +1,5 @@ import { types as sdkTypes, transit } from './sdkLoader'; +import config from '../config'; import Decimal from 'decimal.js'; const apiBaseUrl = () => { @@ -28,7 +29,7 @@ export const typeHandlers = [ ]; const serialize = data => { - return transit.write(data, { typeHandlers }); + return transit.write(data, { typeHandlers, verbose: config.sdk.transitVerbose }); }; const deserialize = str => { From 46b217394ecd7d87b08c141b1c826ec7f0eab99f Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 16 Jun 2020 14:40:18 +0300 Subject: [PATCH 19/41] Update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d3400fa..febb40021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX -- [add] Add util functions for constructing line items in FTW backend - [#1299](https://github.com/sharetribe/ftw-daily/pull/1299) +- [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. [#1303](https://github.com/sharetribe/ftw-daily/pull/1303) From 9c3b7e5024ddff43a2d57285e52180ba95bb683f Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Tue, 9 Jun 2020 14:36:51 +0300 Subject: [PATCH 20/41] 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 fe3e1fb61..f5ebd1077 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 21/41] 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 5c6887ab5..8f856d181 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 22/41] 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 62d9f0102..6f1651c5b 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 2a6a62332..76ca1c436 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 8f856d181..54a734a46 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 05892b841..6745cca03 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 23a5ea21a..fc3499614 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 5c022e128..b189fbef1 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 970bec6fa..2d3aec25c 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 23/41] 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 176d6867d..a49b86604 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 24/41] 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 e98af4093..7c634fc11 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 0acdc0b92..63d2db6ee 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 263d7388b..6a9b354ec 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 aa6307372..10da971d6 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 bd875e770..832a4f1b3 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 fab92d533..377c49a7e 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 25/41] 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 db1ba06de..6294cc183 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 26/41] 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 78b58df24..8a67166e9 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 19df3b2dc..0ba9f5c6f 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 27/41] 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 3f35870a6..51e68f040 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 804a3510c..d654a43e2 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 ad4fca73c..e3dca0672 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 28/41] 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 0ba9f5c6f..a56dd9d00 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 797978c3e..cd0d20a7b 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 29/41] 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 8a67166e9..78b58df24 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 a56dd9d00..be05bab92 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 21626f870..9823e73a3 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 30/41] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index febb40021..85cd114fa 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 31/41] 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 9af2cbeb3..225cd8373 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 da907dd7d..fec140669 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 73681df5e..2222303ae 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 32/41] 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 1cad4b453..e40f7d097 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= From 96cf5a4476ffc48cb3d20fa17cac53225186e802 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Wed, 24 Jun 2020 17:26:48 +0300 Subject: [PATCH 33/41] Add SHARETRIBE_SDK_CLIENT_SECRET to 'yarn run config' script (and app.json) --- app.json | 5 ++++- scripts/config.js | 31 +++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index a21a01d75..ec20b9e79 100644 --- a/app.json +++ b/app.json @@ -8,7 +8,10 @@ "description": "See: Integrating to map providers documentation https://github.com/sharetribe/ftw-daily/blob/master/docs/map-providers.md" }, "REACT_APP_SHARETRIBE_SDK_CLIENT_ID": { - "description": "Client ID (API key). You will get this from the Sharetribe team." + "description": "Client ID (API key). You will find this from the Flex Console." + }, + "SHARETRIBE_SDK_CLIENT_SECRET": { + "description": "Client Secret (API secret token). You will find this from the Flex Console." }, "REACT_APP_STRIPE_PUBLISHABLE_KEY": { "description": "Stripe publishable API key for generating tokens with Stripe API. Use test key (prefix pktest) for development." diff --git a/scripts/config.js b/scripts/config.js index 7de1a5702..0efd62a4f 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -23,7 +23,7 @@ const run = () => { process.on('exit', code => { console.log(` -${chalk.bold.red(`You don't have required .env file!`)} +${chalk.bold.red(`You don't have required .env file!`)} Some environment variables are required before starting Flex template for web. You can create the .env file and configure the variables by running ${chalk.cyan.bold( 'yarn run config' @@ -35,7 +35,7 @@ Some environment variables are required before starting Flex template for web. Y } } else if (hasEnvFile) { console.log(` -${chalk.bold.green('.env file already exists!')} +${chalk.bold.green('.env file already exists!')} Remember to restart the application after editing the environment variables! You can also edit environment variables by editing the .env file directly in your text editor. `); @@ -65,7 +65,7 @@ Remember to restart the application after editing the environment variables! You name: 'createEmptyEnv', message: `Do you want to configure required environment variables now? ${chalk.dim( - `If you don't set up variables now .env file is created based on .env-template file. The application will not work correctly without Flex client ID, Stripe publishable API key and MapBox acces token. + `If you don't set up variables now .env file is created based on .env-template file. The application will not work correctly without Flex client ID, Flex client secret, Stripe publishable API key and MapBox acces token. We recommend setting up the required variables before starting the application!` )}`, default: true, @@ -102,6 +102,10 @@ const mandatoryVariables = settings => { settings.REACT_APP_SHARETRIBE_SDK_CLIENT_ID !== 'change-me' ? { default: settings.REACT_APP_SHARETRIBE_SDK_CLIENT_ID } : {}; + const clientSecretDefaultMaybe = + settings && settings.SHARETRIBE_SDK_CLIENT_SECRET !== '' + ? { default: settings.SHARETRIBE_SDK_CLIENT_SECRET } + : {}; const stripeDefaultMaybe = settings && settings.REACT_APP_STRIPE_PUBLISHABLE_KEY !== '' ? { default: settings.REACT_APP_STRIPE_PUBLISHABLE_KEY } @@ -132,6 +136,17 @@ ${chalk.dim( }, ...clientIdDefaultMaybe, }, + { + type: 'input', + name: 'SHARETRIBE_SDK_CLIENT_SECRET', + message: `What is your Flex client secret? +${chalk.dim( + `Client Secret is needed for privileged transitions with Flex API. +Client secret is connected to client ID. You can find your client secret from Flex Console.` +)} +`, + ...clientSecretDefaultMaybe, + }, { type: 'input', name: 'REACT_APP_STRIPE_PUBLISHABLE_KEY', @@ -166,7 +181,7 @@ If you don't set the Mapbox key, the map components won't work in the applicatio message: `What is your marketplace currency? ${chalk.dim( 'The currency used in the Marketplace must be in ISO 4217 currency code. For example USD, EUR, CAD, AUD, etc. The default value is USD.' -)} +)} `, default: function() { return currencyDefault; @@ -211,7 +226,7 @@ const advancedSettings = settings => { { type: 'input', name: 'REACT_APP_CANONICAL_ROOT_URL', - message: `What is your canonical root URL? + message: `What is your canonical root URL? ${chalk.dim( 'Canonical root URL of the marketplace is needed for social media sharing and SEO optimization. When developing the template application locally URL is usually http://localhost:3000' )} @@ -242,7 +257,7 @@ ${chalk.dim( message: `Do you want to enable default search suggestions? ${chalk.dim( 'This setting enables the Default Search Suggestions in location autocomplete search input. The default value for this setting is true.' -)} +)} `, default: searchesDefault, when: function(answers) { @@ -292,13 +307,13 @@ const askQuestions = settings => { */ const showSuccessMessage = () => { console.log(` -${chalk.green.bold('.env file saved succesfully!')} +${chalk.green.bold('.env file saved succesfully!')} Start the Flex template application by running ${chalk.bold.cyan('yarn run dev')} Note that the .env file is a hidden file so it might not be visible directly in directory listing. If you want to update the environment variables run ${chalk.cyan.bold( 'yarn run config' - )} again or edit the .env file directly. Remember to restart the application after editing the environment variables! + )} again or edit the .env file directly. Remember to restart the application after editing the environment variables! `); }; From 23faf51dd2a9638bbe4c7f938610e9525fde8f78 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 11:53:46 +0300 Subject: [PATCH 34/41] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85cd114fa..37338ac1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [add] Add client secret enquiry to 'yarn run config' script + [#1313](https://github.com/sharetribe/ftw-daily/pull/1313) - [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. From ff9556f7510123532d25984125fe7dd6d88c7aba Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 09:12:13 +0300 Subject: [PATCH 35/41] Set privileged transitions --- src/util/transaction.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/util/transaction.js b/src/util/transaction.js index 9823e73a3..45b301f6f 100644 --- a/src/util/transaction.js +++ b/src/util/transaction.js @@ -343,7 +343,7 @@ export const txRoleIsCustomer = userRole => userRole === TX_TRANSITION_ACTOR_CUS // 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); + return [TRANSITION_REQUEST_PAYMENT, TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY].includes( + transition + ); }; From a11dc9e2b0a2f1883ffbee330fee500d08573fa8 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 09:31:49 +0300 Subject: [PATCH 36/41] Update transaction process definition --- ext/transaction-process/README.md | 11 +++++------ ext/transaction-process/process.edn | 14 ++++++-------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/ext/transaction-process/README.md b/ext/transaction-process/README.md index db3adc465..8b6b8fe9c 100644 --- a/ext/transaction-process/README.md +++ b/ext/transaction-process/README.md @@ -4,9 +4,8 @@ This is the transaction process that the Flex Template for Web is designed to wo The `process.edn` file describes the process flow while the `templates` folder contains notification messages that are used by the process. -The process uses day-based booking and night-based pricing i.e. the quantity of booked units is -defined by the number of nights in the booking. The process has preauthorization and it relies on -the provider to accept booking requests. - -For different process descriptions for varying booking and pricing models, see the -[Flex example processes repository](https://github.com/sharetribe/flex-example-processes) +Bookings in the process are day-based. Pricing uses privileged transitions and +the +[privileged-set-line-items](https://www.sharetribe.com/docs/references/transaction-process-actions/#actionprivileged-set-line-items) +action. The process has preauthorization and it relies on the provider to accept +booking requests. diff --git a/ext/transaction-process/process.edn b/ext/transaction-process/process.edn index 469668ee6..55dbdbdc3 100644 --- a/ext/transaction-process/process.edn +++ b/ext/transaction-process/process.edn @@ -9,22 +9,20 @@ :actions [{:name :action/create-booking, :config {:observe-availability? true}} - {:name :action/calculate-tx-nightly-total-price} - {:name :action/calculate-tx-provider-commission, - :config {:commission 0.1M}} + {:name :action/privileged-set-line-items} {:name :action/stripe-create-payment-intent}], - :to :state/pending-payment} + :to :state/pending-payment + :privileged? true} {:name :transition/request-payment-after-enquiry, :actor :actor.role/customer, :actions [{:name :action/create-booking, :config {:observe-availability? true}} - {:name :action/calculate-tx-nightly-total-price} - {:name :action/calculate-tx-provider-commission, - :config {:commission 0.1M}} + {:name :action/privileged-set-line-items} {:name :action/stripe-create-payment-intent}], :from :state/enquiry, - :to :state/pending-payment} + :to :state/pending-payment + :privileged? true} {:name :transition/expire-payment, :at {:fn/plus From b3a603e0d5a316fce8c324e32b0bdcde920f5881 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 11:18:16 +0300 Subject: [PATCH 37/41] Update default process alias --- src/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.js b/src/config.js index 78b58df24..55971eca0 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 = 'flex-default-process/release-1'; // The transaction line item code for the main unit type in bookings. // From bb08aa59ddb43c9969c7c1b8a4b2ea25c2d21a2a Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 11:50:41 +0300 Subject: [PATCH 38/41] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37338ac1a..b754ebb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [change] Use privileged transitions for price calculation by default and + update the process alias. + [#1314](https://github.com/sharetribe/ftw-daily/pull/1314) - [add] Add client secret enquiry to 'yarn run config' script [#1313](https://github.com/sharetribe/ftw-daily/pull/1313) - [change] Add UI support for flexible pricing and privileged From 710e907784c80c948ffe91fa4a34ce7853f48c77 Mon Sep 17 00:00:00 2001 From: Hannu Lyytikainen Date: Thu, 25 Jun 2020 15:37:25 +0300 Subject: [PATCH 39/41] Add new release 6.0.0 --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b754ebb1a..cca8d830e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +## [v6.0.0] 2020-06-25 + - [change] Use privileged transitions for price calculation by default and update the process alias. [#1314](https://github.com/sharetribe/ftw-daily/pull/1314) diff --git a/package.json b/package.json index df6f22001..a77a14b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "5.0.0", + "version": "6.0.0", "private": true, "license": "Apache-2.0", "dependencies": { From 09e31acad30545db79f0e55eb08c5a9b67764f24 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Wed, 1 Jul 2020 10:09:07 +0300 Subject: [PATCH 40/41] Fix test src/util/dates.test.js --- src/util/dates.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/dates.test.js b/src/util/dates.test.js index a401041f5..303f0bb46 100644 --- a/src/util/dates.test.js +++ b/src/util/dates.test.js @@ -99,7 +99,7 @@ describe('date utils', () => { }); it('should return localized time for "2019-09-18T00:45:00.000Z" with 12h format', () => { const formattingOptions = { - hour: '2-digit', + hour: 'numeric', minute: '2-digit', hour12: true, }; From f4c269fb19e1a715b6fb7f00d37ea2e867989280 Mon Sep 17 00:00:00 2001 From: Jenni Laakso Date: Wed, 1 Jul 2020 10:11:20 +0300 Subject: [PATCH 41/41] Remove unsused util functions from server/api-util --- server/api-util/dates.js | 42 ---------------------------- server/api-util/dates.test.js | 45 ------------------------------ server/api-util/lineItemHelpers.js | 4 --- 3 files changed, 91 deletions(-) delete mode 100644 server/api-util/dates.js delete mode 100644 server/api-util/dates.test.js diff --git a/server/api-util/dates.js b/server/api-util/dates.js deleted file mode 100644 index f4f5739b3..000000000 --- a/server/api-util/dates.js +++ /dev/null @@ -1,42 +0,0 @@ -const moment = require('moment'); - -/** Helper functions for handling dates - * These helper functions are copied from src/util/dates.js - */ - -/** - * Calculate the number of nights between the given dates - * - * @param {Date} startDate start of the time period - * @param {Date} endDate end of the time period - * - * @throws Will throw if the end date is before the start date - * @returns {Number} number of nights between the given dates - */ -exports.nightsBetween = (startDate, endDate) => { - const nights = moment(endDate).diff(startDate, 'days'); - if (nights < 0) { - throw new Error('End date cannot be before start date'); - } - return nights; -}; - -/** - * Calculate the number of days between the given dates - * - * @param {Date} startDate start of the time period - * @param {Date} endDate end of the time period. NOTE: with daily - * bookings, it is expected that this date is the exclusive end date, - * i.e. the last day of the booking is the previous date of this end - * date. - * - * @throws Will throw if the end date is before the start date - * @returns {Number} number of days between the given dates - */ -exports.daysBetween = (startDate, endDate) => { - const days = moment(endDate).diff(startDate, 'days'); - if (days < 0) { - throw new Error('End date cannot be before start date'); - } - return days; -}; diff --git a/server/api-util/dates.test.js b/server/api-util/dates.test.js deleted file mode 100644 index 4fe56dc95..000000000 --- a/server/api-util/dates.test.js +++ /dev/null @@ -1,45 +0,0 @@ -const { nightsBetween, daysBetween } = require('./dates'); - -describe('nightsBetween()', () => { - it('should fail if end date is before start date', () => { - const start = new Date(2017, 0, 2); - const end = new Date(2017, 0, 1); - expect(() => nightsBetween(start, end)).toThrow('End date cannot be before start date'); - }); - it('should handle equal start and end dates', () => { - const d = new Date(2017, 0, 1); - expect(nightsBetween(d, d)).toEqual(0); - }); - it('should calculate night count for a single night', () => { - const start = new Date(2017, 0, 1); - const end = new Date(2017, 0, 2); - expect(nightsBetween(start, end)).toEqual(1); - }); - it('should calculate night count', () => { - const start = new Date(2017, 0, 1); - const end = new Date(2017, 0, 3); - expect(nightsBetween(start, end)).toEqual(2); - }); -}); - -describe('daysBetween()', () => { - it('should fail if end date is before start date', () => { - const start = new Date(2017, 0, 2); - const end = new Date(2017, 0, 1); - expect(() => daysBetween(start, end)).toThrow('End date cannot be before start date'); - }); - it('should handle equal start and end dates', () => { - const d = new Date(2017, 0, 1); - expect(daysBetween(d, d)).toEqual(0); - }); - it('should calculate night count for a single day', () => { - const start = new Date(2017, 0, 1); - const end = new Date(2017, 0, 2); - expect(daysBetween(start, end)).toEqual(1); - }); - it('should calculate day count', () => { - const start = new Date(2017, 0, 1); - const end = new Date(2017, 0, 3); - expect(daysBetween(start, end)).toEqual(2); - }); -}); diff --git a/server/api-util/lineItemHelpers.js b/server/api-util/lineItemHelpers.js index 889b5cd1b..4ef73449f 100644 --- a/server/api-util/lineItemHelpers.js +++ b/server/api-util/lineItemHelpers.js @@ -1,13 +1,9 @@ const Decimal = require('decimal.js'); const moment = require('moment-timezone/builds/moment-timezone-with-data-10-year-range.min'); -const has = require('lodash/has'); const { types } = require('sharetribe-flex-sdk'); const { Money } = types; const { getAmountAsDecimalJS, convertDecimalJSToNumber } = require('./currency'); -const { nightsBetween, daysBetween } = require('./dates'); -const LINE_ITEM_NIGHT = 'line-item/night'; -const LINE_ITEM_DAY = 'line-item/day'; /** Helper functions for constructing line items*/