elysium-attribution 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +365 -0
- package/index.d.ts +213 -0
- package/package.json +34 -6
- package/src/config/index.js +45 -0
- package/src/config/schema.js +85 -0
- package/src/dom/buttonDetector.js +128 -0
- package/src/gateways/GatewayRegistry.js +75 -0
- package/src/gateways/GenericGateway.js +12 -0
- package/src/gateways/PayPalGateway.js +83 -0
- package/src/gateways/PaymentGateway.js +173 -0
- package/src/gateways/RazorpayGateway.js +88 -0
- package/src/gateways/StripeGateway.js +89 -0
- package/src/gateways/index.js +6 -0
- package/src/index.js +151 -0
- package/src/signals/params.js +68 -0
- package/src/signals/referrer.js +106 -0
- package/src/signals/session.js +134 -0
- package/src/signals/touchEngine.js +211 -0
- package/src/signals/transport.js +64 -0
- package/src/signals/visitor.js +82 -0
- package/src/tracking/params.js +68 -0
- package/src/tracking/referrer.js +106 -0
- package/src/tracking/session.js +134 -0
- package/src/tracking/touchTracker.js +202 -0
- package/src/tracking/transport.js +60 -0
- package/src/tracking/visitor.js +82 -0
- package/src/utils/attributionInit.js +56 -3
- package/src/utils/cli.js +69 -17
- package/src/utils/logger.js +31 -0
- package/src/utils/storage.js +110 -0
- package/test/buttonDetector.test.js +26 -0
- package/test/config.test.js +35 -0
- package/test/gateways.test.js +112 -0
- package/test/params.test.js +51 -0
- package/test/referrer.test.js +41 -0
- package/test/session.test.js +112 -0
- package/test/visitor.test.js +35 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
GatewayRegistry,
|
|
5
|
+
PaymentGateway,
|
|
6
|
+
StripeGateway,
|
|
7
|
+
RazorpayGateway,
|
|
8
|
+
PayPalGateway,
|
|
9
|
+
GenericGateway,
|
|
10
|
+
} from '../src/gateways/index.js';
|
|
11
|
+
|
|
12
|
+
describe('Payment Gateways Architecture', () => {
|
|
13
|
+
it('manages default and custom gateways in GatewayRegistry', () => {
|
|
14
|
+
const registry = new GatewayRegistry();
|
|
15
|
+
|
|
16
|
+
assert.ok(registry.get('stripe') instanceof StripeGateway);
|
|
17
|
+
assert.ok(registry.get('razorpay') instanceof RazorpayGateway);
|
|
18
|
+
assert.ok(registry.get('paypal') instanceof PayPalGateway);
|
|
19
|
+
|
|
20
|
+
// Register custom crypto gateway
|
|
21
|
+
class CryptoGateway extends PaymentGateway {
|
|
22
|
+
constructor() {
|
|
23
|
+
super('crypto');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
registry.register('crypto', new CryptoGateway());
|
|
27
|
+
assert.ok(registry.get('crypto') instanceof CryptoGateway);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('PaymentGateway emits lifecycle events on attempt, success, and failure', async () => {
|
|
31
|
+
const gateway = new GenericGateway('testgw');
|
|
32
|
+
const events = [];
|
|
33
|
+
|
|
34
|
+
gateway.on('attempt', (e) => events.push({ type: 'attempt', ...e }));
|
|
35
|
+
gateway.on('success', (e) => events.push({ type: 'success', ...e }));
|
|
36
|
+
gateway.on('failure', (e) => events.push({ type: 'failure', ...e }));
|
|
37
|
+
|
|
38
|
+
await gateway.recordPaymentAttempt({ stage: 'init' });
|
|
39
|
+
await gateway.recordPaymentSuccess({ transactionId: 'tx_999', amount: 150 });
|
|
40
|
+
await gateway.recordPaymentFailure({ code: 'card_declined', message: 'Insufficient funds' });
|
|
41
|
+
|
|
42
|
+
assert.equal(events.length, 3);
|
|
43
|
+
assert.equal(events[0].type, 'attempt');
|
|
44
|
+
assert.equal(events[1].type, 'success');
|
|
45
|
+
assert.equal(events[1].transactionId, 'tx_999');
|
|
46
|
+
assert.equal(events[2].type, 'failure');
|
|
47
|
+
assert.equal(events[2].errorCode, 'card_declined');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('RazorpayGateway wraps options and intercepts success handler', async () => {
|
|
51
|
+
const rzpGateway = new RazorpayGateway();
|
|
52
|
+
let originalHandlerCalled = false;
|
|
53
|
+
let recordedSuccess = null;
|
|
54
|
+
|
|
55
|
+
rzpGateway.on('success', (data) => {
|
|
56
|
+
recordedSuccess = data;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const originalOptions = {
|
|
60
|
+
key: 'rzp_test_123',
|
|
61
|
+
amount: 50000,
|
|
62
|
+
currency: 'INR',
|
|
63
|
+
handler: (res) => {
|
|
64
|
+
originalHandlerCalled = true;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const wrapped = rzpGateway.wrapOptions(originalOptions);
|
|
69
|
+
assert.notEqual(wrapped.handler, originalOptions.handler);
|
|
70
|
+
|
|
71
|
+
// Simulate Razorpay calling the handler upon customer completing payment
|
|
72
|
+
wrapped.handler({
|
|
73
|
+
razorpay_payment_id: 'pay_ABC123',
|
|
74
|
+
razorpay_order_id: 'order_XYZ456',
|
|
75
|
+
razorpay_signature: 'sig_789',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(originalHandlerCalled, true);
|
|
79
|
+
assert.ok(recordedSuccess);
|
|
80
|
+
assert.equal(recordedSuccess.transactionId, 'pay_ABC123');
|
|
81
|
+
assert.equal(recordedSuccess.orderId, 'order_XYZ456');
|
|
82
|
+
assert.equal(recordedSuccess.amount, 500); // 50000 paise -> 500 INR
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('PayPalGateway wraps buttons and intercepts onApprove', async () => {
|
|
86
|
+
const ppGateway = new PayPalGateway();
|
|
87
|
+
let originalApproveCalled = false;
|
|
88
|
+
let recordedSuccess = null;
|
|
89
|
+
|
|
90
|
+
ppGateway.on('success', (data) => {
|
|
91
|
+
recordedSuccess = data;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const originalButtonOpts = {
|
|
95
|
+
createOrder: () => 'order_pp_1',
|
|
96
|
+
onApprove: (data) => {
|
|
97
|
+
originalApproveCalled = true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const wrapped = ppGateway.wrapButtons(originalButtonOpts);
|
|
102
|
+
await wrapped.onApprove({
|
|
103
|
+
orderID: 'PP_ORDER_999',
|
|
104
|
+
payerID: 'PAYER_555',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(originalApproveCalled, true);
|
|
108
|
+
assert.ok(recordedSuccess);
|
|
109
|
+
assert.equal(recordedSuccess.orderId, 'PP_ORDER_999');
|
|
110
|
+
assert.equal(recordedSuccess.payerId, 'PAYER_555');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractMarketingParams } from '../src/tracking/params.js';
|
|
4
|
+
import { configManager } from '../src/config/index.js';
|
|
5
|
+
|
|
6
|
+
describe('Marketing Parameters Extraction Module', () => {
|
|
7
|
+
it('extracts standard UTM parameters', () => {
|
|
8
|
+
const search = '?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&utm_term=shoes&utm_content=banner_top';
|
|
9
|
+
const result = extractMarketingParams(search);
|
|
10
|
+
|
|
11
|
+
assert.equal(result.utm.source, 'google');
|
|
12
|
+
assert.equal(result.utm.medium, 'cpc');
|
|
13
|
+
assert.equal(result.utm.campaign, 'summer_sale');
|
|
14
|
+
assert.equal(result.utm.term, 'shoes');
|
|
15
|
+
assert.equal(result.utm.content, 'banner_top');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to short query parameters if utm_ prefix is omitted', () => {
|
|
19
|
+
const search = '?source=newsletter&medium=email&campaign=welcome_flow';
|
|
20
|
+
const result = extractMarketingParams(search);
|
|
21
|
+
|
|
22
|
+
assert.equal(result.utm.source, 'newsletter');
|
|
23
|
+
assert.equal(result.utm.medium, 'email');
|
|
24
|
+
assert.equal(result.utm.campaign, 'welcome_flow');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('extracts multi-platform ad click IDs', () => {
|
|
28
|
+
const search = '?gclid=test_gclid_123&fbclid=test_fbclid_456&ttclid=test_ttclid_789&msclkid=test_msclkid_101&wbraid=test_wbraid_202&li_fat_id=test_linkedin_303';
|
|
29
|
+
const result = extractMarketingParams(search);
|
|
30
|
+
|
|
31
|
+
assert.equal(result.clickIds.gclid, 'test_gclid_123');
|
|
32
|
+
assert.equal(result.clickIds.fbclid, 'test_fbclid_456');
|
|
33
|
+
assert.equal(result.clickIds.ttclid, 'test_ttclid_789');
|
|
34
|
+
assert.equal(result.clickIds.msclkid, 'test_msclkid_101');
|
|
35
|
+
assert.equal(result.clickIds.wbraid, 'test_wbraid_202');
|
|
36
|
+
assert.equal(result.clickIds.li_fat_id, 'test_linkedin_303');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('extracts configured custom query parameters', () => {
|
|
40
|
+
configManager.init({
|
|
41
|
+
customParams: ['affiliate_id', 'promo_code'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const search = '?affiliate_id=aff_9988&promo_code=SAVE20&other=ignored';
|
|
45
|
+
const result = extractMarketingParams(search);
|
|
46
|
+
|
|
47
|
+
assert.equal(result.custom.affiliate_id, 'aff_9988');
|
|
48
|
+
assert.equal(result.custom.promo_code, 'SAVE20');
|
|
49
|
+
assert.equal(result.custom.other, undefined);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isInternalReferrer, parseReferrer } from '../src/tracking/referrer.js';
|
|
4
|
+
|
|
5
|
+
describe('Referrer Parsing and Classification Module', () => {
|
|
6
|
+
it('identifies internal referrals correctly', () => {
|
|
7
|
+
assert.equal(isInternalReferrer('https://mystore.com/catalog', 'mystore.com'), true);
|
|
8
|
+
assert.equal(isInternalReferrer('https://shop.mystore.com/cart', 'mystore.com'), true);
|
|
9
|
+
assert.equal(isInternalReferrer('https://google.com', 'mystore.com'), false);
|
|
10
|
+
assert.equal(isInternalReferrer('', 'mystore.com'), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('classifies organic search engines', () => {
|
|
14
|
+
const googleResult = parseReferrer('https://www.google.com/search?q=shoes', 'mystore.com');
|
|
15
|
+
assert.equal(googleResult.channel, 'organic_search');
|
|
16
|
+
assert.equal(googleResult.detectedSource, 'google');
|
|
17
|
+
assert.equal(googleResult.detectedMedium, 'organic');
|
|
18
|
+
|
|
19
|
+
const bingResult = parseReferrer('https://www.bing.com/', 'mystore.com');
|
|
20
|
+
assert.equal(bingResult.channel, 'organic_search');
|
|
21
|
+
assert.equal(bingResult.detectedSource, 'bing');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('classifies social networks', () => {
|
|
25
|
+
const fbResult = parseReferrer('https://l.facebook.com/', 'mystore.com');
|
|
26
|
+
assert.equal(fbResult.channel, 'social');
|
|
27
|
+
assert.equal(fbResult.detectedSource, 'facebook');
|
|
28
|
+
assert.equal(fbResult.detectedMedium, 'social');
|
|
29
|
+
|
|
30
|
+
const redditResult = parseReferrer('https://www.reddit.com/r/deals', 'mystore.com');
|
|
31
|
+
assert.equal(redditResult.channel, 'social');
|
|
32
|
+
assert.equal(redditResult.detectedSource, 'reddit');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('filters out internal referrers and marks channel as internal', () => {
|
|
36
|
+
const internalResult = parseReferrer('https://mystore.com/products', 'mystore.com');
|
|
37
|
+
assert.equal(internalResult.isInternal, true);
|
|
38
|
+
assert.equal(internalResult.referrer, null);
|
|
39
|
+
assert.equal(internalResult.channel, 'internal');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
shouldSendTouch,
|
|
5
|
+
saveSuccessfulTouch,
|
|
6
|
+
readTouchHistory,
|
|
7
|
+
TOUCH_HISTORY_KEY,
|
|
8
|
+
getTouchSignature,
|
|
9
|
+
} from '../src/tracking/session.js';
|
|
10
|
+
import { safeLocalStorage } from '../src/utils/storage.js';
|
|
11
|
+
import { configManager } from '../src/config/index.js';
|
|
12
|
+
|
|
13
|
+
describe('Session Tracking and Deduplication Module', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
safeLocalStorage.removeItem(TOUCH_HISTORY_KEY);
|
|
16
|
+
configManager.init({
|
|
17
|
+
sessionWindowMs: 30 * 60 * 1000,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('allows the initial touch when history is empty', () => {
|
|
22
|
+
const payload = {
|
|
23
|
+
visitorId: 'vis-1',
|
|
24
|
+
source: 'google',
|
|
25
|
+
medium: 'cpc',
|
|
26
|
+
campaign: 'shoes',
|
|
27
|
+
};
|
|
28
|
+
assert.equal(shouldSendTouch(payload, []), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects duplicate touch with the same signature within the session window', () => {
|
|
32
|
+
const payload = {
|
|
33
|
+
visitorId: 'vis-1',
|
|
34
|
+
source: 'google',
|
|
35
|
+
medium: 'cpc',
|
|
36
|
+
campaign: 'shoes',
|
|
37
|
+
landingPage: 'https://mystore.com/shoes',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const history = [
|
|
41
|
+
{
|
|
42
|
+
payload,
|
|
43
|
+
response: { id: 't-1', visitorId: 'vis-1', channel: 'cpc', touchedAt: '' },
|
|
44
|
+
recordedAt: Date.now() - 5000, // 5 seconds ago
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
assert.equal(shouldSendTouch(payload, history), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('protects paid marketing attribution from being overwritten by a direct navigation in the same session', () => {
|
|
52
|
+
const adPayload = {
|
|
53
|
+
visitorId: 'vis-1',
|
|
54
|
+
source: 'facebook',
|
|
55
|
+
medium: 'cpc',
|
|
56
|
+
campaign: 'retargeting',
|
|
57
|
+
fbclid: 'fb_123',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const directPayload = {
|
|
61
|
+
visitorId: 'vis-1',
|
|
62
|
+
source: null,
|
|
63
|
+
medium: null,
|
|
64
|
+
campaign: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const history = [
|
|
68
|
+
{
|
|
69
|
+
payload: adPayload,
|
|
70
|
+
response: { id: 't-1', visitorId: 'vis-1', channel: 'cpc', touchedAt: '' },
|
|
71
|
+
recordedAt: Date.now() - 60000, // 1 min ago
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Direct navigation should not overwrite the active paid touch
|
|
76
|
+
assert.equal(shouldSendTouch(directPayload, history), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('allows a new touch when session window has expired', () => {
|
|
80
|
+
const payload = {
|
|
81
|
+
visitorId: 'vis-1',
|
|
82
|
+
source: 'google',
|
|
83
|
+
medium: 'cpc',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const history = [
|
|
87
|
+
{
|
|
88
|
+
payload,
|
|
89
|
+
response: { id: 't-1', visitorId: 'vis-1', channel: 'cpc', touchedAt: '' },
|
|
90
|
+
recordedAt: Date.now() - (35 * 60 * 1000), // 35 minutes ago (> 30 min window)
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
assert.equal(shouldSendTouch(payload, history), true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('limits saved history to 50 items and purges older records (FIFO)', () => {
|
|
98
|
+
for (let i = 0; i < 60; i++) {
|
|
99
|
+
saveSuccessfulTouch({
|
|
100
|
+
payload: { visitorId: 'vis-1', index: i },
|
|
101
|
+
response: { id: `touch-${i}`, visitorId: 'vis-1', channel: 'direct', touchedAt: '' },
|
|
102
|
+
recordedAt: Date.now() + i,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const history = readTouchHistory();
|
|
107
|
+
assert.equal(history.length, 50);
|
|
108
|
+
// Oldest 10 items (0..9) should be dropped, item 10 should be the first
|
|
109
|
+
assert.equal(history[0].payload.index, 10);
|
|
110
|
+
assert.equal(history[49].payload.index, 59);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { generateUUID, getVisitorId, setVisitorId, VISITOR_ID_KEY } from '../src/tracking/visitor.js';
|
|
4
|
+
import { safeLocalStorage } from '../src/utils/storage.js';
|
|
5
|
+
|
|
6
|
+
describe('Visitor Tracking Module', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
safeLocalStorage.removeItem(VISITOR_ID_KEY);
|
|
9
|
+
// Reset memory cache if any
|
|
10
|
+
setVisitorId('');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('generates a valid UUIDv4 string', () => {
|
|
14
|
+
const uuid = generateUUID();
|
|
15
|
+
assert.equal(typeof uuid, 'string');
|
|
16
|
+
assert.match(uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('generates and persists visitorId', () => {
|
|
20
|
+
const id1 = getVisitorId();
|
|
21
|
+
assert.ok(id1);
|
|
22
|
+
assert.equal(typeof id1, 'string');
|
|
23
|
+
|
|
24
|
+
// Calling again returns the exact same visitorId
|
|
25
|
+
const id2 = getVisitorId();
|
|
26
|
+
assert.equal(id1, id2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('allows explicit override with setVisitorId', () => {
|
|
30
|
+
const customId = 'custom-visitor-12345';
|
|
31
|
+
setVisitorId(customId);
|
|
32
|
+
assert.equal(getVisitorId(), customId);
|
|
33
|
+
assert.equal(safeLocalStorage.getItem(VISITOR_ID_KEY), customId);
|
|
34
|
+
});
|
|
35
|
+
});
|