diff --git a/package-lock.json b/package-lock.json index 672d55fa..9734df61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2237,7 +2237,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "invert-kv": { @@ -2429,7 +2429,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "which": { @@ -2447,7 +2447,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "requires": { "string-width": "^1.0.1", @@ -2479,7 +2479,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -2499,7 +2499,7 @@ }, "yargs": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-11.1.0.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-11.1.0.tgz", "integrity": "sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A==", "requires": { "cliui": "^4.0.0", @@ -2545,7 +2545,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "get-value": { @@ -3876,7 +3876,7 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "minipass": { @@ -3920,7 +3920,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -5471,7 +5471,7 @@ }, "p-is-promise": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=" }, "p-limit": { @@ -5537,7 +5537,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-is-inside": { @@ -5960,7 +5960,7 @@ }, "resolve": { "version": "1.7.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "resolved": "http://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", "dev": true, "requires": { @@ -6629,7 +6629,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "strip-hex-prefix": { @@ -6860,7 +6860,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true } @@ -7223,7 +7223,7 @@ }, "uuid": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/uuid/-/uuid-2.0.1.tgz", "integrity": "sha1-wqMN7bPlNdcsz4LjQ5QaULqFM6w=" }, "validate-npm-package-license": { @@ -7322,7 +7322,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "requires": { "string-width": "^1.0.1", @@ -7354,7 +7354,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" diff --git a/routes/schemas.js b/routes/schemas.js index dfc527ff..1c9ed919 100644 --- a/routes/schemas.js +++ b/routes/schemas.js @@ -114,12 +114,23 @@ module.exports = { .keys() .pattern( /^(IMPRESSION|CLICK)$/, - Joi.object({ min: numericString.default('0'), max: numericString.default('0') }) + Joi.object({ min: numericString.required(), max: numericString.required() }) ), eventSubmission: Joi.object({ allow: Joi.array().items(Joi.object()) }), nonce: Joi.string(), created: Joi.number(), - activeFrom: Joi.number() + activeFrom: Joi.number(), + priceMultiplicationRules: Joi.array().items( + Joi.object({ + multiplier: Joi.number().precision(10), // max 10 decimal places + amount: numericString, + evType: Joi.array().items(Joi.string().lowercase()), + country: Joi.array().items(Joi.string().lowercase()), + publisher: Joi.array().items(Joi.string()), + osType: Joi.array().items(Joi.string().lowercase()) + }) + ), + priceDynamicAdjustment: Joi.bool() }).required() }, validatorMessage: { diff --git a/services/sentry/eventAggregator.js b/services/sentry/eventAggregator.js index b8ac79ab..be975b2e 100644 --- a/services/sentry/eventAggregator.js +++ b/services/sentry/eventAggregator.js @@ -73,7 +73,7 @@ function makeRecorder(channelId) { // this will be saved in the channel object, which is passed into the eventReducer // Record the events - aggr = events.reduce(eventReducer.reduce.bind(null, channel), aggr) + aggr = events.reduce(eventReducer.reduce.bind(null, channel, session), aggr) if (cfg.AGGR_THROTTLE) { throttledPersistAndReset() return { success: true } diff --git a/services/sentry/lib/eventReducer.js b/services/sentry/lib/eventReducer.js index 8b627b78..6b3c1efb 100644 --- a/services/sentry/lib/eventReducer.js +++ b/services/sentry/lib/eventReducer.js @@ -6,10 +6,9 @@ function newAggr(channelId) { return { channelId, created: new Date(), events: {}, totals: {}, earners: [] } } -function reduce(channel, initialAggr, ev) { +function reduce(channel, session, initialAggr, ev) { let aggr = { ...initialAggr } - - const payout = getPayout(channel, ev) + const payout = getPayout(channel, ev, session) if (payout) { aggr.events[ev.type] = mergeEv(initialAggr.events[ev.type], payout) aggr = { ...aggr, ...mergeToGlobalAcc(aggr, ev.type, payout) } diff --git a/services/sentry/lib/getPayout.js b/services/sentry/lib/getPayout.js index 0d90b419..1785b4f8 100644 --- a/services/sentry/lib/getPayout.js +++ b/services/sentry/lib/getPayout.js @@ -1,18 +1,66 @@ +/* eslint-disable no-nested-ternary */ const BN = require('bn.js') const toBalancesKey = require('../toBalancesKey') -function getPayout(channel, ev) { - if (ev.type === 'IMPRESSION' && ev.publisher) { - // add the minimum price for the event to the current amount - return [toBalancesKey(ev.publisher), new BN(channel.spec.minPerImpression || 1)] - } - if (ev.type === 'CLICK' && ev.publisher) { - return [ - toBalancesKey(ev.publisher), - new BN((channel.spec.pricingBounds && channel.spec.pricingBounds.CLICK.min) || 0) - ] +function getPayout(channel, ev, session) { + if (ev.type && ev.publisher && ['IMPRESSION', 'CLICK'].includes(ev.type.toUpperCase())) { + const [minPrice, maxPrice] = getPriceBounds(channel.spec, ev.type) + const price = channel.spec.priceMultiplicationRules + ? payout(channel.spec.priceMultiplicationRules, ev, session, maxPrice, minPrice) + : minPrice + return [toBalancesKey(ev.publisher), new BN(price.toString())] } return null } +function payout(rules, ev, session, maxPrice, startPrice) { + const match = isRuleMatching.bind(null, ev, session) + const matchingRules = rules.filter(match) + let finalPrice = startPrice + + if (matchingRules.length > 0) { + const divisionExponent = new BN(10).pow(new BN(18, 10)) + const firstFixed = matchingRules.find(x => x.amount) + const priceByRules = firstFixed + ? new BN(firstFixed.amount) + : startPrice + .mul( + new BN( + ( + matchingRules + .filter(x => x.multiplier) + .map(x => x.multiplier) + .reduce((a, b) => a * b, 1) * 1e18 + ).toString() + ) + ) + .div(divisionExponent) + finalPrice = BN.min(maxPrice, priceByRules) + } + + return finalPrice +} + +function isRuleMatching(ev, session, rule) { + return rule.evType + ? rule.evType.includes(ev.type.toLowerCase()) + : true && rule.publisher + ? rule.publisher.includes(ev.publisher) + : true && rule.osType + ? rule.osType.includes(session.os && session.os.toLowerCase()) + : true && rule.country + ? rule.country.includes(session.country && session.country.toLowerCase()) + : true +} + +function getPriceBounds(spec, evType) { + const { pricingBounds, minPerImpression, maxPerImpression } = spec + const fromPricingBounds = pricingBounds && + pricingBounds[evType] && [new BN(pricingBounds[evType].min), new BN(pricingBounds[evType].max)] + if (evType === 'IMPRESSION') { + return fromPricingBounds || [new BN(minPerImpression || 1), new BN(maxPerImpression || 1)] + } + return fromPricingBounds || [new BN(0), new BN(0)] +} + module.exports = getPayout diff --git a/test/fixtures.js b/test/fixtures.js index b06f69be..fe5b3a58 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -1,3 +1,4 @@ +const BN = require('bn.js') const dummyVals = require('./prep-db/mongo') const validatorMessage = { @@ -8,6 +9,15 @@ const validatorMessage = { balances: { myAwesomePublisher: '214000000000000000000000', anotherPublisher: '2' } } +const payoutChannel = { + depositAmount: '100', + spec: { + minPerImpression: '8', + maxPerImpression: '64', + pricingBounds: { CLICK: { min: new BN(23), max: new BN(100) } } + } +} + module.exports = { createChannel: [ [ @@ -413,5 +423,223 @@ module.exports = { `ValidationError: "value" at position 0 fails because [child "channelId" fails because ["channelId" is required]]` ] ] - } + }, + payoutRules: [ + [ + { + depositAmount: '100', + spec: { + minPerImpression: '8', + maxPerImpression: '64', + pricingBounds: { CLICK: { min: new BN(23), max: new BN(100) } } + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'IMPRESSION' }, + {}, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(8)], + `pricingBounds: impression event` + ], + [ + { + depositAmount: '100', + spec: { + minPerImpression: '8', + maxPerImpression: '64', + pricingBounds: { CLICK: { min: new BN(23), max: new BN(100) } } + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'CLICK' }, + {}, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(23)], + `pricingBounds: click event` + ], + [ + { + depositAmount: '100', + spec: { + minPerImpression: '8', + maxPerImpression: '64', + pricingBounds: { CLICK: { min: new BN(23), max: new BN(100) } } + } + }, + { type: 'CLOSE' }, + {}, + null, + `pricingBounds: close event ` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [{ amount: '10', country: ['us'], evType: ['click'] }] + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'IMPRESSION' }, + {}, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(8)], + `fixedAmount: impression` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [{ amount: '10', country: ['us'], evType: ['click'] }] + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'CLICK' }, + { country: 'US' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(10)], + `fixedAmount (country, publisher): click` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [{ amount: '10' }] + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'CLICK' }, + { country: 'US' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(10)], + `fixedAmount (all): click` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [{ amount: '10000' }] + } + }, + { publisher: '0xce07cbb7e054514d590a0262c93070d838bfba2e', type: 'IMPRESSION' }, + {}, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(64)], + `fixedAmount (all): price should not exceed maxPerImpressionPrice` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [{ amount: '10000' }] + } + }, + { publisher: '0xce07cbb7e054514d590a0262c93070d838bfba2e', type: 'CLICK' }, + { country: 'US' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(100)], + `fixedAmount (all): price should not exceed event pricingBound max` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [ + { amount: '10', country: ['us'], evType: ['click'] }, + { + amount: '12', + country: ['us'], + evType: ['click'], + publisher: ['0xce07CbB7e054514D590a0262C93070D838bFBA2e'] + } + ] + } + }, + { publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', type: 'CLICK' }, + { country: 'US' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(10)], + `fixedAmount (country, pulisher): should choose first fixedAmount rule` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [ + { + amount: '15', + country: ['us'], + evType: ['click'], + publisher: ['0xce07CbB7e054514D590a0262C93070D838bFBA2e'], + osType: ['android'] + } + ] + } + }, + { + publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', + type: 'CLICK' + }, + { country: 'US', osType: 'android' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(15)], + `fixedAmount (country, pulisher, osType): click` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + priceMultiplicationRules: [ + { + multiplier: 1.2, + country: ['us'], + evType: ['click'], + publisher: ['0xce07CbB7e054514D590a0262C93070D838bFBA2e'], + osType: ['android'] + }, + { + amount: '12', + country: ['us'], + evType: ['click'], + publisher: ['0xce07CbB7e054514D590a0262C93070D838bFBA2e'], + osType: ['android'] + } + ] + } + }, + { + publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', + type: 'CLICK' + }, + { country: 'US', osType: 'android' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN(12)], + `fixedAmount (country, osType, publisher): choose fixedAmount rule over multiplier if present` + ], + [ + { + ...payoutChannel, + spec: { + ...payoutChannel.spec, + pricingBounds: { + CLICK: { + min: new BN((1e18).toString()).toString(), + max: new BN((100e18).toString()).toString() + } + }, + priceMultiplicationRules: [ + { + multiplier: 1.2, + country: ['us'], + evType: ['click'], + publisher: ['0xce07CbB7e054514D590a0262C93070D838bFBA2e'], + osType: ['android'] + }, + { + multiplier: 1.2 + } + ] + } + }, + { + publisher: '0xce07CbB7e054514D590a0262C93070D838bFBA2e', + type: 'CLICK' + }, + { country: 'US', osType: 'android' }, + ['0xce07cbb7e054514d590a0262c93070d838bfba2e', new BN('1440000000000000000')], + `multiplier (country, osType, publisher | all) - apply all multiplier rules` + ] + ] } diff --git a/test/index.js b/test/index.js index 2dbec6b2..893a2049 100755 --- a/test/index.js +++ b/test/index.js @@ -357,11 +357,9 @@ tape('eventReducer: newAggr', function(t) { }) tape('getPayout: get event payouts', function(t) { - const pricingBounds = { CLICK: { min: new BN(23) } } - const channel = { depositAmount: '100', spec: { minPerImpression: '8', pricingBounds } } - t.deepEqual(getPayout(channel, { publisher: 'test1', type: 'IMPRESSION' }), ['test1', new BN(8)]) - t.deepEqual(getPayout(channel, { publisher: 'test2', type: 'CLICK' }), ['test2', new BN(23)]) - t.deepEqual(getPayout(channel, { type: 'CLOSE' }), null) + fixtures.payoutRules.forEach(([channel, event, session, expectedResult, message]) => { + t.deepEqual(getPayout(channel, event, session), expectedResult, message) + }) t.end() }) @@ -381,10 +379,10 @@ tape('eventReducer: reduce', function(t) { // reduce 100 events for (let i = 0; i < 100; i += 1) { - eventReducer.reduce(channel, aggr, event) + eventReducer.reduce(channel, {}, aggr, event) } - const result = eventReducer.reduce(channel, aggr, event) + const result = eventReducer.reduce(channel, {}, aggr, event) t.equal(result.channelId, channel.id, 'should have same channel id') t.equal( @@ -398,7 +396,7 @@ tape('eventReducer: reduce', function(t) { 'should have the correct number of eventsPayouts' ) - const closeReduce = eventReducer.reduce(channel, aggr, { + const closeReduce = eventReducer.reduce(channel, {}, aggr, { type: 'CLOSE' }) diff --git a/test/integration.js b/test/integration.js index 75ebea6c..a571d812 100755 --- a/test/integration.js +++ b/test/integration.js @@ -465,9 +465,39 @@ tape('should record clicks', async function(t) { t.end() }) -tape('analytics routes return correct values', async function(t) { +tape('should record: correct payout clicks', async function(t) { const channel = getValidEthChannel() + channel.spec = { + ...channel.spec, + pricingBounds: { + CLICK: { + min: '1', + max: '2' + } + }, + priceMultiplicationRules: [{ amount: '2', country: ['US'], evType: ['CLICK'] }] + } + const num = 66 + const evs = genEvents(num, randomAddress(), 'CLICK') + // Submit a new channel; we submit it to both sentries to avoid 404 when propagating messages + await Promise.all([ + fetchPost(`${leaderUrl}/channel`, dummyVals.auth.leader, channel), + fetchPost(`${followerUrl}/channel`, dummyVals.auth.follower, channel) + ]) + await postEvsAsCreator(leaderUrl, channel.id, evs, { 'cf-ipcountry': 'US' }) + // Technically we don't need to tick, since the events should be reflected immediately + const analytics = await fetch( + `${leaderUrl}/analytics/${channel.id}?eventType=CLICK&metric=eventPayouts` + ).then(r => r.json()) + + t.equal(analytics.aggr[0].value, (num * 2).toString(), 'proper payout amount') + + t.end() +}) + +tape('analytics routes return correct values', async function(t) { + const channel = getValidEthChannel() // Submit a new channel; we submit it to both sentries to avoid 404 when propagating messages await Promise.all([ fetchPost(`${leaderUrl}/channel`, dummyVals.auth.leader, channel),