lamassu-server/lib/customers.js
Liordino Neto c808ca3be9 feat: added the compliance/customers route
feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header

feat: created customer profile page and started a transition feature
from the customer list

refactor: make components out of customers list table and profile page

feat: selecting a customer now transitions to its profile page

feat: added the customer transactions list table

fix: fix tx class button margins

fix: fix tx class icon margins on the customer list

fix: fixed crypto value

style: fixed the table column widths

feat: added the requirements column (no data yet, though)

feat: added the header with the customer details (no image yet, though)

feat: created the skeleton for the properties cards

feat: create the breadcrumb on the customer profile page (no link yet)

feat: added the children container in the property card

feat: added block customer action button

feat: added action buttons to the property cards

feat: added a children prop to the property card component

feat: added extra properties to the customer gql query

feat: added override fields to the customers gql query

style: added conditional styles to the property card component

feat: added children to the customer property cards

feat: create the edit button function on the property card

feat: add error properties to the txs (from gql)

style: fix action left editing action button and right property card
margins

feat: created a mutation to update a customer

feat: added the customer auth override state to the gql query

feat: fix the routing to the individual customer profile pages

feat: made the 'Customers' label on the breadcrumb work as a link

style: fixed the breadcrumb separator

style: fixed the customer name style

feat: made the action to block and authorize a customer as a toggle

feat: removed the 'Super user' switch (left for v2)

style: added the crossed camera icon on the photo

style: fixed the rejected icons

refactor: refactored some styles that were repetitive

refactor: created constants for the override possible states

feat: created functions for the authorization and blocking of overrides

refactor: renamed setOverride to updateCustomer

fix: remove current unused features

feat: make the property cards fields read-only

feat: setup id card photo and front camera photo image servers

feat: add id card photo on the corresponding property card

feat: add front camera photo on the customer profile header

feat: added gql cache to update the front-end after any mutation

style: added the crossed camera icon when there's no id card photo

refactor: extracted the PropertyCard component to another file

fix: deactivated the cache for the transactions (no need for it)

refactor: removed unused styles

fix: fixed front-camera-photo img path

fix: changed gql local data updates from cache to query refetch

refactor: move override status constants to the property card class

refactor: make the image servers URI a const dependent on the build

fix: remove requirements column from customer tx table (left for future
version)

fix: add aliases to gql query to correctly show errors on tx table

style: fix the transaction errors styles

feat: add terms and conditions page

feat: add modal preview

feat: remove preview

fix: increase space between switch and fields

feat: added the compliance/customers route

feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header

fix: removed unused code

refactor: move transactions to a custom resolver in the customer's query

refactor: break the CustomerProfile component into several smaller ones

style: changed the table row error color from red to no change and the
error text from tomato to comet

fix: removed repeated function (wrong merge)

fix: make the updateCustomer function updates only what's explicitly
told so

style: return with the table row error style

refactor: create a function to test if a value is null prior to passing
it through another function

fix: make t&c changes backwards compatible

chore: bump eslint import library to activate rule

fix: stop showing object on empty column

fix: get machine logs page up-to-date

fix: small admin fixes

feat: add terms and conditions page

feat: add modal preview

feat: remove preview

fix: increase space between switch and fields

feat: added the compliance/customers route

feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header

fix: make t&c changes backwards compatible

fix: stop showing object on empty column

fix: get machine logs page up-to-date

feat: add terms and conditions page

feat: add modal preview

feat: remove preview

fix: increase space between switch and fields

feat: added the compliance/customers route

feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header

fix: make t&c changes backwards compatible

fix: stop showing object on empty column

fix: get machine logs page up-to-date

fix: small admin fixes

feat: create add machine page

feat: add terms and conditions page

feat: add modal preview

feat: remove preview

fix: increase space between switch and fields

feat: added the compliance/customers route

feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header

fix: make t&c changes backwards compatible

fix: stop showing object on empty column

fix: get machine logs page up-to-date

feat: create add machine page

fix: fixed wrong merging

fix: more fixes from last merge

fix: export needed functions that wasn't exported from the customers
module

fix: removed the customer profile route from the header

fix: replaced old dataTable with new component

feat: added onClick event to new DataTable
2020-05-09 18:19:20 +01:00

576 lines
17 KiB
JavaScript

const uuid = require('uuid')
const Pgp = require('pg-promise')()
const _ = require('lodash/fp')
const crypto = require('crypto')
const makeDir = require('make-dir')
const path = require('path')
const fs = require('fs')
const util = require('util')
const moment = require('moment')
const db = require('./db')
const BN = require('./bn')
const anonymous = require('../lib/constants').anonymousCustomer
const complianceOverrides = require('./compliance_overrides')
const users = require('./users')
const options = require('./options')
const writeFile = util.promisify(fs.writeFile)
const NUM_RESULTS = 1000
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
const frontCameraBaseDir = _.get('frontCameraDir', options)
/**
* Add new customer
*
* @name add
* @function
*
* @param {object} Customer object (with phone number)
*
* @returns {object} Newly created customer
*/
function add (customer) {
const sql = 'insert into customers (id, phone, phone_at) values ($1, $2, now()) returning *'
return db.one(sql, [uuid.v4(), customer.phone])
.then(populateOverrideUsernames)
.then(computeStatus)
.then(populateDailyVolume)
.then(camelize)
}
/**
* Get single customer by phone
* Phone numbers are unique per customer
*
* @name get
* @function
*
* @param {string} phone Customer's phone number
*
* @returns {object} Customer
*/
function get (phone) {
const sql = 'select * from customers where phone=$1'
return db.oneOrNone(sql, [phone])
.then(populateDailyVolume)
.then(camelize)
}
/**
* Update customer record
*
* @name update
* @function
*
* @param {string} id Customer's id
* @param {object} data Fields to update
* @param {string} Acting user's token
*
* @returns {Promise} Newly updated Customer
*/
function update (id, data, userToken, txId) {
const formattedData = _.omit(['id'], _.mapKeys(_.snakeCase, data))
const updateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken))
const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
' where id=$1 returning *'
return db.one(sql, [id])
.then(addComplianceOverrides(id, updateData, userToken))
.then(populateOverrideUsernames)
.then(computeStatus)
.then((it) => populateDailyVolume(it, txId))
.then(camelize)
}
/**
* Update customer record
*
* @name updateCustomer
* @function
*
* @param {string} id Customer's id
* @param {object} data Fields to update
*
* @returns {Promise} Newly updated Customer
*/
async function updateCustomer (id, data) {
const formattedData = _.pick(
['authorized_override', 'id_card_photo_override', 'id_card_data_override', 'sms_override'],
_.mapKeys(_.snakeCase, data))
const sql = Pgp.helpers.update(formattedData, _.keys(formattedData), 'customers') +
' where id=$1'
await db.none(sql, [id])
return getCustomerById(id)
}
/**
* Get customer by id
*
* @name getById
* @function
*
* @param {string} id Customer's unique id
* @param {string} userToken Acting user's token
*
* @returns {object} Customer found
*/
function getById (id, userToken) {
const sql = 'select * from customers where id=$1'
return db.oneOrNone(sql, [id])
.then(populateOverrideUsernames)
.then(computeStatus)
.then(populateDailyVolume)
.then(camelize)
}
/**
* Get and calculate customer's daily volume
* for both cash_in & cash_out txs
*
* @name getDailyVolume
* @function
*
* @param {string} id Customer's id
* @param {string} txId current tx, to be ignored in the query
* @returns {Bignumber} Customer's daily volume
*/
function getDailyVolume (id, txId) {
const queries = txId ? getDailyVolumeMinusCurrentTxQueries(id, txId) : getDailyVolumeQueries(id)
return Promise.all(queries).then(([cashIn, cashOut]) => {
const dailyVolume = BN(cashIn.total).add(cashOut.total)
const hoursTillLimitClear = getHoursTillLimitClear(cashIn.maxdate, cashOut.maxdate)
return { dailyVolume, hoursTillLimitClear }
})
}
function getDailyVolumeQueries (id) {
return [
db.one(`select coalesce(sum(fiat), 0) as total, max(created) as maxdate from cash_in_txs
where customer_id=$1
and created > now() - interval '1 day'`, [id]),
db.one(`select coalesce(sum(fiat), 0) as total, max(created) as maxdate from cash_out_txs
where customer_id=$1
and created > now() - interval '1 day'`, [id])
]
}
function getDailyVolumeMinusCurrentTxQueries (id, txId) {
return [
db.one(`select coalesce(sum(fiat), 0) as total, max(created) as maxdate from cash_in_txs
where customer_id=$1
and id!=$2
and created > now() - interval '1 day'`, [id, txId]),
db.one(`select coalesce(sum(fiat), 0) as total, max(created) as maxdate from cash_out_txs
where customer_id=$1
and id!=$2
and created > now() - interval '1 day'`, [id, txId])
]
}
function getHoursTillLimitClear (cashInDate, cashOutDate) {
let startDate = moment()
startDate = startDate.subtract(1, 'days')
const cashInMoment = moment(cashInDate || startDate)
const cashOutMoment = moment(cashOutDate || startDate)
const cashInDuration = moment.duration(cashInMoment.diff(startDate)).asHours()
const cashOutDuration = moment.duration(cashOutMoment.diff(startDate)).asHours()
return _.ceil(_.max([cashInDuration, cashOutDuration, 0]))
}
/**
* Camelize customer fields
* Note: return null if customer is undefined
*
* @name camelize
* @function
*
* @param {object} customer Customer with snake_case fields
* @returns {object} Camelized Customer object
*/
function camelize (customer) {
return customer ? _.mapKeys(_.camelCase, customer) : null
}
/**
* Populate customer object
* with dailyVolume information
*
* @name populateDailyVolume
* @function
*
* @param {object} customer Customer object
* @returns {object} Customer object populated with dailyVolume
*/
function populateDailyVolume (customer, txId) {
if (!customer) return
return getDailyVolume(customer.id, txId).then(({ dailyVolume, hoursTillLimitClear }) => {
let withHours = _.set('hours_till_limit_clear', hoursTillLimitClear, customer)
return _.set('daily_volume', dailyVolume, withHours)
})
}
/**
* Get all available complianceTypes
* that can be overriden (excluding hard_limit)
*
* @name getComplianceTypes
* @function
*
* @returns {array} Array of compliance types' names
*/
function getComplianceTypes () {
return [
'sms',
'id_card_data',
'id_card_photo',
'front_camera',
'sanctions',
'authorized' ]
}
function enhanceAtFields (fields) {
const updateableFields = [
'id_card_data',
'id_card_photo',
'front_camera',
'sanctions',
'authorized'
]
const updatedFields = _.intersection(updateableFields, _.keys(fields))
const atFields = _.fromPairs(_.map(f => [`${f}_at`, 'now()^'], updatedFields))
return _.merge(fields, atFields)
}
/**
* Add *override_by and *override_at fields with acting user's token
* and date of override respectively before saving to db.
*
* @name enhanceOverrideFields
* @function
*
* @param {object} fields Override fields to be enhanced
* @param {string} userToken Acting user's token
* @returns {object} fields enhanced with *_by and *_at fields
*/
function enhanceOverrideFields (fields, userToken) {
if (!userToken) return fields
// Populate with computedFields (user who overrode and overriden timestamps date)
return _.reduce(_.assign, fields, _.map((type) => {
return (fields[type + '_override'])
? {
[type + '_override_by']: userToken,
[type + '_override_at']: 'now()^'
}
: {}
}, getComplianceTypes()))
}
/**
* Save new compliance override records
*
* Take the override fields that are modified in customer and create
* a compliance override record in db for each compliance type.
*
* @name addComplianceOverrides
* @function
*
* @param {string} id Customer's id
* @param {object} customer Customer that is updating
* @param {string} userToken Acting user's token
*
* @returns {promise} Result from compliance_overrides creation
*/
function addComplianceOverrides (id, customer, userToken) {
// Prepare compliance overrides to save
const overrides = _.map(field => {
const complianceName = field + '_override'
return (customer[complianceName]) ? {
customerId: id,
complianceType: field,
overrideBy: userToken,
verification: customer[complianceName]
} : null
}, getComplianceTypes())
// Save all the updated override fields
return Promise.all(_.map(complianceOverrides.add, _.compact(overrides)))
.then(() => customer)
}
/**
* Compute status field
*
* Status field indicates the last
* compliance user has verified
*
* @name computeStatus
* @function
*
* @param {object} Customer object
* @returns {object} Customer populated with status field
*/
function computeStatus (customer) {
if (!customer) return null
/**
* Populate with status field
*
*/
const status = _.maxBy('value', [{
label: 'Phone',
value: customer.phone_at
}, {
label: 'ID card',
value: customer.id_card_at
}, {
label: 'Sanctions',
value: customer.sanctions_at
}, {
label: 'Front camera',
value: customer.front_camera_at
}, {
label: 'ID card image',
value: customer.id_card_image_at
}])
return _.assign(customer, {
status: _.get('label', status)
})
}
/**
* Populate the customer object with user names
* for override fields ( fields ending with _override_by )
*
* @name populateOverrideUsernames
* @function
*
* @param {object} customer Customer object to populate
* @returns {promise} Customer with populated *by_name fields
*/
function populateOverrideUsernames (customer) {
const fieldsToUpdate = _.map(field => {
return {
token: customer[field + '_override_by'],
field: field + '_override_by_name'
}
}, getComplianceTypes())
const queryTokens = _.map('token', fieldsToUpdate)
return users.getByIds(queryTokens)
.then(usersList => {
return _.map(userField => {
const user = _.find({token: userField.token}, usersList)
return {
[userField.field]: user ? user.name : null
}
}, fieldsToUpdate)
})
.then(_.reduce(_.assign, customer))
}
/**
* Query all customers
*
* Add status as computed column,
* which will indicate the name of the latest
* compliance verfication completed by user.
*
* @returns {array} Array of customers populated with status field
*/
function batch () {
const sql = `select * from customers
where id != $1
order by created desc limit $2`
return db.any(sql, [ anonymous.uuid, NUM_RESULTS ])
.then(customers => Promise.all(_.map(customer => {
return populateOverrideUsernames(customer)
.then(computeStatus)
.then(populateDailyVolume)
.then(camelize)
}, customers)))
}
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
*/
function getCustomersList () {
const sql = `select id, name, authorized_override, front_camera_path, phone, sms_override,
id_card_data, id_card_data_override, id_card_data_expiration, id_card_photo_path,
id_card_photo_override, total_txs, total_spent, created as last_active,
fiat as last_tx_fiat, fiat_code as last_tx_fiat_code, tx_class as last_tx_class
from (
select c.id, c.name, c.authorized_override, c.front_camera_path, c.phone, c.sms_override,
c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.id_card_photo_path,
c.id_card_photo_override, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() over (partition by c.id order by t.created desc) as rn,
sum(case when t.id is not null then 1 else 0 end) over (partition by c.id) as total_txs,
coalesce(sum(t.fiat) over (partition by c.id), 0) as total_spent
from customers c left outer join (
select 'cashIn' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_in_txs where send_confirmed = true union
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
where c.id != $1
) as cl where rn = 1
limit $2`
return db.any(sql, [ anonymous.uuid, NUM_RESULTS ])
.then(customers => Promise.all(_.map(customer => {
return populateOverrideUsernames(customer)
.then(camelize)
}, customers)))
}
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
*/
function getCustomerById (id) {
const sql = `select id, name, authorized_override, front_camera_path, phone, sms_override,
id_card_data, id_card_data_override, id_card_data_expiration, id_card_photo_path,
id_card_photo_override, total_txs, total_spent, created as last_active,
fiat as last_tx_fiat, fiat_code as last_tx_fiat_code, tx_class as last_tx_class
from (
select c.id, c.name, c.authorized_override, c.front_camera_path, c.phone, c.sms_override,
c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.id_card_photo_path,
c.id_card_photo_override, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() over (partition by c.id order by t.created desc) as rn,
count(0) over (partition by c.id) as total_txs,
sum(t.fiat) over (partition by c.id) as total_spent
from customers c inner join (
select 'cashIn' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_in_txs where send_confirmed = true union
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
where c.id = $1
) as cl where rn = 1`
return db.oneOrNone(sql, [id])
.then(populateOverrideUsernames)
.then(camelize)
}
/**
* @param {String} id customer id
* @param {Object} patch customer update record
* @returns {Promise<Object>} new patch to be applied
*/
function updatePhotoCard (id, patch) {
return Promise.resolve(patch)
.then(patch => {
// Base64 encoded image /9j/4AAQSkZJRgABAQAAAQ..
const imageData = _.get('idCardPhotoData', patch)
if (_.isEmpty(imageData)) {
return patch
}
// remove idCardPhotoData from the update record
const newPatch = _.omit('idCardPhotoData', patch)
// decode the base64 string to binary data
const decodedImageData = Buffer.from(imageData, 'base64')
// workout the image hash
// i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50
const hash = crypto
.createHash('sha256')
.update(imageData)
.digest('hex')
// workout the image folder
// i.e. 24/0e/85
const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash)))))
// i.e. ../<lamassu-server-home>/idphotocard/24/0e/85
const dirname = path.join(idPhotoCardBasedir, rpath)
// create the directory tree if needed
_.attempt(() => makeDir.sync(dirname))
// i.e. ../<lamassu-server-home>/idphotocard/24/0e/85/240e85ff2e4bb931f235985dd01....jpg
const filename = path.join(dirname, hash + '.jpg')
// update db record patch
// i.e. {
// "idCardPhotoPath": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg",
// "idCardPhotoAt": "now()"
// }
newPatch.idCardPhotoPath = path.join(rpath, hash + '.jpg')
newPatch.idCardPhotoAt = 'now()'
// write image file
return writeFile(filename, decodedImageData)
.then(() => newPatch)
})
}
function updateFrontCamera (id, patch) {
return Promise.resolve(patch)
.then(patch => {
// Base64 encoded image /9j/4AAQSkZJRgABAQAAAQ..
const imageData = _.get('frontCameraData', patch)
if (_.isEmpty(imageData)) {
return patch
}
// remove idCardPhotoData from the update record
const newPatch = _.omit('frontCameraData', patch)
// decode the base64 string to binary data
const decodedImageData = Buffer.from(imageData, 'base64')
// workout the image hash
// i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50
const hash = crypto
.createHash('sha256')
.update(imageData)
.digest('hex')
// workout the image folder
// i.e. 24/0e/85
const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash)))))
// i.e. ../<lamassu-server-home>/idphotocard/24/0e/85
const dirname = path.join(frontCameraBaseDir, rpath)
// create the directory tree if needed
_.attempt(() => makeDir.sync(dirname))
// i.e. ../<lamassu-server-home>/idphotocard/24/0e/85/240e85ff2e4bb931f235985dd01....jpg
const filename = path.join(dirname, hash + '.jpg')
// update db record patch
// i.e. {
// "idCardPhotoPath": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg",
// "idCardPhotoAt": "now()"
// }
newPatch.frontCameraPath = path.join(rpath, hash + '.jpg')
newPatch.frontCameraAt = 'now()'
// write image file
return writeFile(filename, decodedImageData)
.then(() => newPatch)
})
}
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera }