feat: add loyalty panel screen and structure
feat: add coupons table feat: add coupons to schema fix: coupon schema feat: coupon table feat: add coupon top button feat: add first coupon button feat: delete coupon feat: coupon modal fix: clear discount on modal close fix: modal input formatting feat: add new coupons fix: button positioning fix: remove loyalty panel sidebar fix: coupon screen matching specs fix: coupon modal feat: send coupon data to machine on poll fix: available coupons bool feat: coupon endpoint feat: transaction discount migration feat: post-discount rates refactor: bills feat: version string fix: bill saving on db feat: coupon soft-delete fix: coupon soft delete fix: bill receiving feat: remove cryptoAtoms update during tx fix: tx trading fix: bills feat: start trades rework fix: remove code fix: code review
This commit is contained in:
parent
64315cfd80
commit
7fe8799edc
18 changed files with 622 additions and 13 deletions
|
|
@ -44,7 +44,7 @@ function insertNewBills (t, billRows, machineTx) {
|
||||||
if (_.isEmpty(bills)) return Promise.resolve([])
|
if (_.isEmpty(bills)) return Promise.resolve([])
|
||||||
|
|
||||||
const dbBills = _.map(cashInLow.massage, bills)
|
const dbBills = _.map(cashInLow.massage, bills)
|
||||||
const columns = _.keys(dbBills[0])
|
const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time']
|
||||||
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
||||||
|
|
||||||
return t.none(sql)
|
return t.none(sql)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ const PENDING_INTERVAL_MS = 60 * T.minutes
|
||||||
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']),
|
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']),
|
||||||
convertBigNumFields, _.mapKeys(_.snakeCase))
|
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||||
|
|
||||||
|
const massageUpdates = _.flow(_.omit(['cryptoAtoms', 'direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']),
|
||||||
|
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||||
|
|
||||||
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
||||||
|
|
||||||
function convertBigNumFields (obj) {
|
function convertBigNumFields (obj) {
|
||||||
|
|
@ -62,7 +65,7 @@ function insert (t, tx) {
|
||||||
function update (t, tx, changes) {
|
function update (t, tx, changes) {
|
||||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||||
|
|
||||||
const dbChanges = massage(changes)
|
const dbChanges = isFinalTxStage(changes) ? massage(changes) : massageUpdates(changes)
|
||||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
||||||
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
||||||
|
|
||||||
|
|
@ -136,3 +139,7 @@ function isClearToSend (oldTx, newTx) {
|
||||||
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
||||||
(newTx.created > now - PENDING_INTERVAL_MS)
|
(newTx.created > now - PENDING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalTxStage (txChanges) {
|
||||||
|
return txChanges.send
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ function post (machineTx, pi) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerTrades (pi, newBills) {
|
function registerTrades (pi, r) {
|
||||||
_.forEach(bill => pi.buy(bill), newBills)
|
_.forEach(bill => pi.buy(bill, r.tx), r.newBills)
|
||||||
}
|
}
|
||||||
|
|
||||||
function logAction (rec, tx) {
|
function logAction (rec, tx) {
|
||||||
|
|
@ -92,7 +92,7 @@ function postProcess (r, pi, isBlacklisted, addressReuse) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
registerTrades(pi, r.newBills)
|
registerTrades(pi, r)
|
||||||
|
|
||||||
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
||||||
|
|
||||||
|
|
|
||||||
24
lib/coupon-manager.js
Normal file
24
lib/coupon-manager.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const db = require('./db')
|
||||||
|
const uuid = require('uuid')
|
||||||
|
|
||||||
|
function getAvailableCoupons () {
|
||||||
|
const sql = `select * from coupons where soft_deleted=false`
|
||||||
|
return db.any(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoupon (code) {
|
||||||
|
const sql = `select * from coupons where code=$1 and soft_deleted=false`
|
||||||
|
return db.oneOrNone(sql, [code])
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCoupon (code, discount) {
|
||||||
|
const sql = `insert into coupons (id, code, discount) values ($1, $2, $3) returning *`
|
||||||
|
return db.one(sql, [uuid.v4(), code, discount])
|
||||||
|
}
|
||||||
|
|
||||||
|
function softDeleteCoupon (couponId) {
|
||||||
|
const sql = `update coupons set soft_deleted=true where id=$1`
|
||||||
|
return db.none(sql, [couponId])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAvailableCoupons, getCoupon, createCoupon, softDeleteCoupon }
|
||||||
|
|
@ -13,6 +13,7 @@ const settingsLoader = require('../../new-settings-loader')
|
||||||
// const tokenManager = require('../../token-manager')
|
// const tokenManager = require('../../token-manager')
|
||||||
const blacklist = require('../../blacklist')
|
const blacklist = require('../../blacklist')
|
||||||
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch
|
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch
|
||||||
|
const couponManager = require('../../coupon-manager')
|
||||||
|
|
||||||
const serverVersion = require('../../../package.json').version
|
const serverVersion = require('../../../package.json').version
|
||||||
|
|
||||||
|
|
@ -174,6 +175,13 @@ const typeDefs = gql`
|
||||||
ip_address: String
|
ip_address: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Coupon {
|
||||||
|
id: ID!
|
||||||
|
code: String!
|
||||||
|
discount: Int!
|
||||||
|
soft_deleted: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Transaction {
|
type Transaction {
|
||||||
id: ID!
|
id: ID!
|
||||||
txClass: String!
|
txClass: String!
|
||||||
|
|
@ -255,6 +263,13 @@ const typeDefs = gql`
|
||||||
config: JSONObject
|
config: JSONObject
|
||||||
blacklist: [Blacklist]
|
blacklist: [Blacklist]
|
||||||
# userTokens: [UserToken]
|
# userTokens: [UserToken]
|
||||||
|
coupons: [Coupon]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupportLogsResponse {
|
||||||
|
id: ID!
|
||||||
|
timestamp: Date!
|
||||||
|
deviceId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MachineAction {
|
enum MachineAction {
|
||||||
|
|
@ -279,6 +294,8 @@ const typeDefs = gql`
|
||||||
# revokeToken(token: String!): UserToken
|
# revokeToken(token: String!): UserToken
|
||||||
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||||
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||||
|
createCoupon(code: String!, discount: Int!): Coupon
|
||||||
|
softDeleteCoupon(couponId: ID!): Coupon
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -329,6 +346,7 @@ const resolvers = {
|
||||||
accounts: () => settingsLoader.loadAccounts(),
|
accounts: () => settingsLoader.loadAccounts(),
|
||||||
blacklist: () => blacklist.getBlacklist(),
|
blacklist: () => blacklist.getBlacklist(),
|
||||||
// userTokens: () => tokenManager.getTokenList()
|
// userTokens: () => tokenManager.getTokenList()
|
||||||
|
coupons: () => couponManager.getAvailableCoupons()
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
|
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
|
||||||
|
|
@ -351,6 +369,8 @@ const resolvers = {
|
||||||
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
|
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
|
||||||
blacklist.insertIntoBlacklist(cryptoCode, address),
|
blacklist.insertIntoBlacklist(cryptoCode, address),
|
||||||
// revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
|
// revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
|
||||||
|
createCoupon: (...[, { code, discount }]) => couponManager.createCoupon(code, discount),
|
||||||
|
softDeleteCoupon: (...[, { couponId }]) => couponManager.softDeleteCoupon(couponId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,11 @@ function plugins (settings, deviceId) {
|
||||||
.then(row => row.id)
|
.then(row => row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchCurrentAvailableCoupons () {
|
||||||
|
const sql = `select * from coupons where soft_deleted=false`
|
||||||
|
return db.any(sql).then(v => v.length)
|
||||||
|
}
|
||||||
|
|
||||||
function mapCoinSettings (coinParams) {
|
function mapCoinSettings (coinParams) {
|
||||||
const cryptoCode = coinParams[0]
|
const cryptoCode = coinParams[0]
|
||||||
const cryptoNetwork = coinParams[1]
|
const cryptoNetwork = coinParams[1]
|
||||||
|
|
@ -222,11 +227,13 @@ function plugins (settings, deviceId) {
|
||||||
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
||||||
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
||||||
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
||||||
|
const currentAvailableCouponsPromise = fetchCurrentAvailableCoupons()
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
buildAvailableCassettes(),
|
buildAvailableCassettes(),
|
||||||
pingPromise,
|
pingPromise,
|
||||||
currentConfigVersionPromise
|
currentConfigVersionPromise,
|
||||||
|
currentAvailableCouponsPromise
|
||||||
].concat(tickerPromises, balancePromises, testnetPromises)
|
].concat(tickerPromises, balancePromises, testnetPromises)
|
||||||
|
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
|
|
@ -236,16 +243,18 @@ function plugins (settings, deviceId) {
|
||||||
const cryptoCodesCount = cryptoCodes.length
|
const cryptoCodesCount = cryptoCodes.length
|
||||||
const tickers = arr.slice(3, cryptoCodesCount + 3)
|
const tickers = arr.slice(3, cryptoCodesCount + 3)
|
||||||
const balances = arr.slice(cryptoCodesCount + 3, 2 * cryptoCodesCount + 3)
|
const balances = arr.slice(cryptoCodesCount + 3, 2 * cryptoCodesCount + 3)
|
||||||
const testNets = arr.slice(2 * cryptoCodesCount + 3)
|
const testNets = arr.slice(2 * cryptoCodesCount + 3, arr.length - 1)
|
||||||
const coinParams = _.zip(cryptoCodes, testNets)
|
const coinParams = _.zip(cryptoCodes, testNets)
|
||||||
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
|
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
|
||||||
|
const areThereAvailableCoupons = arr[arr.length - 1] > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cassettes,
|
cassettes,
|
||||||
rates: buildRates(tickers),
|
rates: buildRates(tickers),
|
||||||
balances: buildBalances(balances),
|
balances: buildBalances(balances),
|
||||||
coins: _.zipWith(_.assign, coinsWithoutRate, tickers),
|
coins: _.zipWith(_.assign, coinsWithoutRate, tickers),
|
||||||
configVersion
|
configVersion,
|
||||||
|
areThereAvailableCoupons
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -440,18 +449,18 @@ function plugins (settings, deviceId) {
|
||||||
* Trader functions
|
* Trader functions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buy (rec) {
|
function buy (rec, tx) {
|
||||||
return buyAndSell(rec, true)
|
return buyAndSell(rec, true, tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sell (rec) {
|
function sell (rec) {
|
||||||
return buyAndSell(rec, false)
|
return buyAndSell(rec, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buyAndSell (rec, doBuy) {
|
function buyAndSell (rec, doBuy, tx) {
|
||||||
const cryptoCode = rec.cryptoCode
|
const cryptoCode = rec.cryptoCode
|
||||||
const fiatCode = rec.fiatCode
|
const fiatCode = rec.fiatCode
|
||||||
const cryptoAtoms = doBuy ? rec.cryptoAtoms : rec.cryptoAtoms.neg()
|
const cryptoAtoms = doBuy ? fiatToCrypto(tx, rec) : rec.cryptoAtoms.neg()
|
||||||
|
|
||||||
const market = [fiatCode, cryptoCode].join('')
|
const market = [fiatCode, cryptoCode].join('')
|
||||||
|
|
||||||
|
|
@ -467,6 +476,38 @@ function plugins (settings, deviceId) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateCrypto (cryptoAtoms, cryptoCode) {
|
||||||
|
const DECIMAL_PLACES = 3
|
||||||
|
if (cryptoAtoms.eq(0)) return cryptoAtoms
|
||||||
|
|
||||||
|
const scale = 5 // TODO: change this to coins.displayScale when coins have that attribute
|
||||||
|
const scaleFactor = BN(10).pow(scale)
|
||||||
|
|
||||||
|
return BN(cryptoAtoms).truncated().div(scaleFactor)
|
||||||
|
.round(DECIMAL_PLACES).times(scaleFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fiatToCrypto (tx, rec) {
|
||||||
|
const usableFiat = rec.fiat - rec.cashInFee
|
||||||
|
|
||||||
|
const commissions = configManager.getCommissions(tx.cryptoCode, deviceId, settings.config)
|
||||||
|
const tickerRate = BN(tx.rawTickerPrice)
|
||||||
|
const discount = getDiscountRate(tx.discount, commissions[tx.direction])
|
||||||
|
const rate = tickerRate.mul(discount).round(5)
|
||||||
|
const unitScale = coinUtils.getCryptoCurrency(tx.cryptoCode).unitScale
|
||||||
|
const unitScaleFactor = BN(10).pow(unitScale)
|
||||||
|
|
||||||
|
return truncateCrypto(BN(usableFiat).div(rate.div(unitScaleFactor)), tx.cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscountRate (discount, commission) {
|
||||||
|
const bnDiscount = discount ? BN(discount) : BN(0)
|
||||||
|
const bnCommission = BN(commission)
|
||||||
|
const percentageDiscount = BN(1).sub(bnDiscount.div(100))
|
||||||
|
const percentageCommission = bnCommission.div(100)
|
||||||
|
return BN(1).add(percentageDiscount.mul(percentageCommission))
|
||||||
|
}
|
||||||
|
|
||||||
function consolidateTrades (cryptoCode, fiatCode) {
|
function consolidateTrades (cryptoCode, fiatCode) {
|
||||||
const market = [fiatCode, cryptoCode].join('')
|
const market = [fiatCode, cryptoCode].join('')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ const E = require('./error')
|
||||||
const customers = require('./customers')
|
const customers = require('./customers')
|
||||||
const logs = require('./logs')
|
const logs = require('./logs')
|
||||||
const compliance = require('./compliance')
|
const compliance = require('./compliance')
|
||||||
|
const couponManager = require('./coupon-manager')
|
||||||
|
const BN = require('./bn')
|
||||||
|
|
||||||
const version = require('../package.json').version
|
const version = require('../package.json').version
|
||||||
|
|
||||||
|
|
@ -213,6 +215,37 @@ function verifyTx (req, res, next) {
|
||||||
.catch(next)
|
.catch(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyCoupon (req, res, next) {
|
||||||
|
couponManager.getCoupon(req.body.codeInput)
|
||||||
|
.then(coupon => {
|
||||||
|
if (!coupon) return next()
|
||||||
|
|
||||||
|
const transaction = req.body.tx
|
||||||
|
const commissions = configManager.getCommissions(transaction.cryptoCode, req.deviceId, req.settings.config)
|
||||||
|
const tickerRate = BN(transaction.rawTickerPrice)
|
||||||
|
const discount = getDiscountRate(coupon.discount, commissions[transaction.direction])
|
||||||
|
const rates = {
|
||||||
|
[transaction.cryptoCode]: {
|
||||||
|
[transaction.direction]: (transaction.direction === 'cashIn')
|
||||||
|
? tickerRate.mul(discount).round(5)
|
||||||
|
: tickerRate.div(discount).round(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(req, res, {
|
||||||
|
coupon: coupon,
|
||||||
|
newRates: rates
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscountRate (discount, commission) {
|
||||||
|
const percentageDiscount = BN(1).sub(BN(discount).div(100))
|
||||||
|
const percentageCommission = BN(commission).div(100)
|
||||||
|
return BN(1).add(percentageDiscount.mul(percentageCommission))
|
||||||
|
}
|
||||||
|
|
||||||
function addOrUpdateCustomer (req) {
|
function addOrUpdateCustomer (req) {
|
||||||
const customerData = req.body
|
const customerData = req.body
|
||||||
const machineVersion = req.query.version
|
const machineVersion = req.query.version
|
||||||
|
|
@ -450,7 +483,8 @@ const configRequiredRoutes = [
|
||||||
'/event',
|
'/event',
|
||||||
'/phone_code',
|
'/phone_code',
|
||||||
'/customer',
|
'/customer',
|
||||||
'/tx'
|
'/tx',
|
||||||
|
'/verify_coupon'
|
||||||
]
|
]
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
@ -477,6 +511,7 @@ app.post('/state', stateChange)
|
||||||
|
|
||||||
app.post('/verify_user', verifyUser)
|
app.post('/verify_user', verifyUser)
|
||||||
app.post('/verify_transaction', verifyTx)
|
app.post('/verify_transaction', verifyTx)
|
||||||
|
app.post('/verify_coupon', verifyCoupon)
|
||||||
|
|
||||||
app.post('/phone_code', getCustomerWithPhoneCode)
|
app.post('/phone_code', getCustomerWithPhoneCode)
|
||||||
app.patch('/customer/:id', updateCustomer)
|
app.patch('/customer/:id', updateCustomer)
|
||||||
|
|
|
||||||
19
migrations/1603886141913-coupon-codes.js
Normal file
19
migrations/1603886141913-coupon-codes.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
var db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
const sql =
|
||||||
|
[
|
||||||
|
`create table coupons (
|
||||||
|
id uuid primary key,
|
||||||
|
code text not null,
|
||||||
|
discount smallint not null,
|
||||||
|
soft_deleted boolean default false )`,
|
||||||
|
`create unique index uq_code on coupons using btree(code) where not soft_deleted`
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'alter table cash_in_txs add column discount smallint',
|
||||||
|
'alter table cash_out_txs add column discount smallint'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
15
migrations/1604934042127-clean-bills.js
Normal file
15
migrations/1604934042127-clean-bills.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'ALTER TABLE bills DROP COLUMN crypto_atoms',
|
||||||
|
'ALTER TABLE bills DROP COLUMN cash_in_fee_crypto',
|
||||||
|
'ALTER TABLE bills DROP COLUMN crypto_atoms_after_fee'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
16
migrations/1606910357208-change-trades.js
Normal file
16
migrations/1606910357208-change-trades.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'alter table trades add column tx_in_id uuid unique',
|
||||||
|
'alter table trades add constraint fk_tx_in foreign key (tx_in_id) references cash_in_txs (id)',
|
||||||
|
'alter table trades add column tx_out_id uuid unique',
|
||||||
|
'alter table trades add constraint fk_tx_out foreign key (tx_in_id) references cash_out_txs (id)'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
138
new-lamassu-admin/src/pages/LoyaltyPanel/CouponCodes.js
Normal file
138
new-lamassu-admin/src/pages/LoyaltyPanel/CouponCodes.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||||
|
import { makeStyles, Box } from '@material-ui/core'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { Link, Button, IconButton } from 'src/components/buttons'
|
||||||
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
import DataTable from 'src/components/tables/DataTable'
|
||||||
|
import { H2, TL1 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
|
||||||
|
|
||||||
|
import styles from './CouponCodes.styles'
|
||||||
|
import CouponCodesModal from './CouponCodesModal'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const GET_COUPONS = gql`
|
||||||
|
query coupons {
|
||||||
|
coupons {
|
||||||
|
id
|
||||||
|
code
|
||||||
|
discount
|
||||||
|
soft_deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const SOFT_DELETE_COUPON = gql`
|
||||||
|
mutation softDeleteCoupon($couponId: ID!) {
|
||||||
|
softDeleteCoupon(couponId: $couponId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const CREATE_COUPON = gql`
|
||||||
|
mutation createCoupon($code: String!, $discount: Int!) {
|
||||||
|
createCoupon(code: $code, discount: $discount) {
|
||||||
|
id
|
||||||
|
code
|
||||||
|
discount
|
||||||
|
soft_deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Coupons = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const toggleModal = () => setShowModal(!showModal)
|
||||||
|
|
||||||
|
const { data: couponResponse, loading } = useQuery(GET_COUPONS)
|
||||||
|
|
||||||
|
const [softDeleteCoupon] = useMutation(SOFT_DELETE_COUPON, {
|
||||||
|
refetchQueries: () => ['coupons']
|
||||||
|
})
|
||||||
|
|
||||||
|
const [createCoupon] = useMutation(CREATE_COUPON, {
|
||||||
|
refetchQueries: () => ['coupons']
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCoupon = (code, discount) => {
|
||||||
|
createCoupon({ variables: { code: code, discount: discount } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
header: 'Coupon Code',
|
||||||
|
width: 300,
|
||||||
|
textAlign: 'left',
|
||||||
|
size: 'sm',
|
||||||
|
view: t => t.code
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Discount',
|
||||||
|
width: 220,
|
||||||
|
textAlign: 'left',
|
||||||
|
size: 'sm',
|
||||||
|
view: t => (
|
||||||
|
<>
|
||||||
|
<TL1 inline>{t.discount}</TL1> % in commissions
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Delete',
|
||||||
|
width: 100,
|
||||||
|
textAlign: 'center',
|
||||||
|
size: 'sm',
|
||||||
|
view: t => (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
softDeleteCoupon({ variables: { couponId: t.id } })
|
||||||
|
}}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleSection title="Discount Coupons"></TitleSection>
|
||||||
|
{!loading && !R.isEmpty(couponResponse.coupons) && (
|
||||||
|
<Box
|
||||||
|
marginBottom={4}
|
||||||
|
marginTop={-5}
|
||||||
|
className={classes.tableWidth}
|
||||||
|
display="flex"
|
||||||
|
justifyContent="flex-end">
|
||||||
|
<Link color="primary" onClick={toggleModal}>
|
||||||
|
Add new coupon
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!loading && !R.isEmpty(couponResponse.coupons) && (
|
||||||
|
<DataTable
|
||||||
|
elements={elements}
|
||||||
|
data={R.path(['coupons'])(couponResponse)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading && R.isEmpty(couponResponse.coupons) && (
|
||||||
|
<Box display="flex" alignItems="left" flexDirection="column">
|
||||||
|
<H2>Currently, there are no active coupon codes on your network.</H2>
|
||||||
|
<Button onClick={toggleModal}>Add coupon</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<CouponCodesModal
|
||||||
|
showModal={showModal}
|
||||||
|
toggleModal={toggleModal}
|
||||||
|
addCoupon={addCoupon}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default Coupons
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { spacer, fontPrimary, primaryColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
footer: {
|
||||||
|
margin: [['auto', 0, spacer * 3, 'auto']]
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
marginTop: -5,
|
||||||
|
color: primaryColor,
|
||||||
|
fontFamily: fontPrimary
|
||||||
|
},
|
||||||
|
modalLabel1: {
|
||||||
|
marginTop: 20
|
||||||
|
},
|
||||||
|
modalLabel2Wrapper: {
|
||||||
|
marginTop: 40,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start'
|
||||||
|
},
|
||||||
|
discountInput: {
|
||||||
|
display: 'flex',
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
color: primaryColor,
|
||||||
|
fontFamily: fontPrimary,
|
||||||
|
fontSize: 24,
|
||||||
|
marginLeft: 8,
|
||||||
|
marginTop: 15
|
||||||
|
},
|
||||||
|
tableWidth: {
|
||||||
|
width: 620
|
||||||
|
}
|
||||||
|
}
|
||||||
125
new-lamassu-admin/src/pages/LoyaltyPanel/CouponCodesModal.js
Normal file
125
new-lamassu-admin/src/pages/LoyaltyPanel/CouponCodesModal.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import Modal from 'src/components/Modal'
|
||||||
|
import Tooltip from 'src/components/Tooltip'
|
||||||
|
import { Button } from 'src/components/buttons'
|
||||||
|
import { TextInput, NumberInput } from 'src/components/inputs/base'
|
||||||
|
import { H1, H3, TL1, P } from 'src/components/typography'
|
||||||
|
|
||||||
|
import styles from './CouponCodes.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const CouponCodesModal = ({ showModal, toggleModal, addCoupon }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [codeField, setCodeField] = useState('')
|
||||||
|
const [discountField, setDiscountField] = useState('')
|
||||||
|
const [invalidCode, setInvalidCode] = useState(false)
|
||||||
|
const [invalidDiscount, setInvalidDiscount] = useState(false)
|
||||||
|
|
||||||
|
const handleCodeChange = event => {
|
||||||
|
if (event.target.value === '') {
|
||||||
|
setInvalidCode(false)
|
||||||
|
}
|
||||||
|
setCodeField(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscountChange = event => {
|
||||||
|
if (event.target.value === '') {
|
||||||
|
setInvalidDiscount(false)
|
||||||
|
}
|
||||||
|
setDiscountField(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCodeField('')
|
||||||
|
setDiscountField('')
|
||||||
|
setInvalidCode(false)
|
||||||
|
setInvalidDiscount(false)
|
||||||
|
toggleModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddCoupon = () => {
|
||||||
|
if (codeField.trim() === '') {
|
||||||
|
setInvalidCode(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!validDiscount(discountField)) {
|
||||||
|
setInvalidDiscount(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (codeField.trim() !== '' && validDiscount(discountField)) {
|
||||||
|
addCoupon(R.toUpper(codeField.trim()), parseInt(discountField))
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDiscount = discount => {
|
||||||
|
const parsedDiscount = parseInt(discount)
|
||||||
|
return parsedDiscount >= 0 && parsedDiscount <= 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showModal && (
|
||||||
|
<Modal
|
||||||
|
closeOnBackdropClick={true}
|
||||||
|
width={600}
|
||||||
|
height={500}
|
||||||
|
handleClose={handleClose}
|
||||||
|
open={true}>
|
||||||
|
<H1 className={classes.modalTitle}>Add coupon code discount</H1>
|
||||||
|
<H3 className={classes.modalLabel1}>Coupon code name</H3>
|
||||||
|
<TextInput
|
||||||
|
error={invalidCode}
|
||||||
|
name="coupon-code"
|
||||||
|
autoFocus
|
||||||
|
id="coupon-code"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
width={338}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
value={codeField}
|
||||||
|
inputProps={{ style: { textTransform: 'uppercase' } }}
|
||||||
|
/>
|
||||||
|
<div className={classes.modalLabel2Wrapper}>
|
||||||
|
<H3 className={classes.modalLabel2}>Define discount rate</H3>
|
||||||
|
<Tooltip width={304}>
|
||||||
|
<P>
|
||||||
|
The discount rate inserted will be applied to the commissions of
|
||||||
|
all transactions performed with this respective coupon code.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
(It should be a number between 0 (zero) and 100 (one hundred)).
|
||||||
|
</P>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className={classes.discountInput}>
|
||||||
|
<NumberInput
|
||||||
|
error={invalidDiscount}
|
||||||
|
name="coupon-discount"
|
||||||
|
id="coupon-discount"
|
||||||
|
size="lg"
|
||||||
|
width={50}
|
||||||
|
onChange={handleDiscountChange}
|
||||||
|
value={discountField}
|
||||||
|
decimalScale={0}
|
||||||
|
className={classes.discountInputField}
|
||||||
|
/>
|
||||||
|
<TL1 inline className={classes.inputLabel}>
|
||||||
|
%
|
||||||
|
</TL1>
|
||||||
|
</div>
|
||||||
|
<div className={classes.footer}>
|
||||||
|
<Button onClick={handleAddCoupon}>Add coupon</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CouponCodesModal
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const IndividualDiscounts = () => {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndividualDiscounts
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const LoyaltyDiscounts = () => {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoyaltyDiscounts
|
||||||
97
new-lamassu-admin/src/pages/LoyaltyPanel/LoyaltyPanel.js
Normal file
97
new-lamassu-admin/src/pages/LoyaltyPanel/LoyaltyPanel.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Route,
|
||||||
|
Switch,
|
||||||
|
Redirect,
|
||||||
|
useLocation,
|
||||||
|
useHistory
|
||||||
|
} from 'react-router-dom'
|
||||||
|
|
||||||
|
import Sidebar from 'src/components/layout/Sidebar'
|
||||||
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
|
||||||
|
import CouponCodes from './CouponCodes'
|
||||||
|
import IndividualDiscounts from './IndividualDiscounts'
|
||||||
|
import LoyaltyDiscounts from './LoyaltyDiscounts'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
grid: {
|
||||||
|
flex: 1,
|
||||||
|
height: '100%'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 48,
|
||||||
|
paddingTop: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const innerRoutes = [
|
||||||
|
{
|
||||||
|
key: 'individual-discounts',
|
||||||
|
label: 'Individual Discounts',
|
||||||
|
route: '/compliance/loyalty/individual-discounts',
|
||||||
|
component: IndividualDiscounts
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loyalty-discounts',
|
||||||
|
label: 'Loyalty Discounts',
|
||||||
|
route: '/compliance/loyalty/discounts',
|
||||||
|
component: LoyaltyDiscounts
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coupon-codes',
|
||||||
|
label: 'Coupon Codes',
|
||||||
|
route: '/compliance/loyalty/coupons',
|
||||||
|
component: CouponCodes
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const Routes = ({ wizard }) => (
|
||||||
|
<Switch>
|
||||||
|
<Redirect
|
||||||
|
exact
|
||||||
|
from="/compliance/loyalty"
|
||||||
|
to="/compliance/loyalty/individual-discounts"
|
||||||
|
/>
|
||||||
|
<Route exact path="/" />
|
||||||
|
{innerRoutes.map(({ route, component: Page, key }) => (
|
||||||
|
<Route path={route} key={key}>
|
||||||
|
<Page name={key} wizard={wizard} />
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LoyaltyPanel = ({ wizard = false }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const history = useHistory()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const isSelected = it => location.pathname === it.route
|
||||||
|
|
||||||
|
const onClick = it => history.push(it.route)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleSection title="Loyalty Panel"></TitleSection>
|
||||||
|
<Grid container className={classes.grid}>
|
||||||
|
<Sidebar
|
||||||
|
data={innerRoutes}
|
||||||
|
isSelected={isSelected}
|
||||||
|
displayName={it => it.label}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
<div className={classes.content}>
|
||||||
|
<Routes wizard={wizard} />
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoyaltyPanel
|
||||||
|
|
@ -21,6 +21,7 @@ import ConfigMigration from 'src/pages/ConfigMigration'
|
||||||
import { Customers, CustomerProfile } from 'src/pages/Customers'
|
import { Customers, CustomerProfile } from 'src/pages/Customers'
|
||||||
import Funding from 'src/pages/Funding'
|
import Funding from 'src/pages/Funding'
|
||||||
import Locales from 'src/pages/Locales'
|
import Locales from 'src/pages/Locales'
|
||||||
|
import Coupons from 'src/pages/LoyaltyPanel/CouponCodes'
|
||||||
import MachineLogs from 'src/pages/MachineLogs'
|
import MachineLogs from 'src/pages/MachineLogs'
|
||||||
import CashCassettes from 'src/pages/Maintenance/CashCassettes'
|
import CashCassettes from 'src/pages/Maintenance/CashCassettes'
|
||||||
import MachineStatus from 'src/pages/Maintenance/MachineStatus'
|
import MachineStatus from 'src/pages/Maintenance/MachineStatus'
|
||||||
|
|
@ -208,6 +209,12 @@ const tree = [
|
||||||
route: '/compliance/blacklist',
|
route: '/compliance/blacklist',
|
||||||
component: Blacklist
|
component: Blacklist
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'discount-coupons',
|
||||||
|
label: 'Discount Coupons',
|
||||||
|
route: '/compliance/loyalty/coupons',
|
||||||
|
component: Coupons
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'customer',
|
key: 'customer',
|
||||||
route: '/compliance/customer/:id',
|
route: '/compliance/customer/:id',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue