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.
@@ -0,0 +1,85 @@
1
+ import { logger } from '../utils/logger.js';
2
+
3
+ export const DEFAULT_CONFIG = {
4
+ apiKey: '',
5
+ apiBaseUrl: 'https://asbTemp-production.up.railway.app',
6
+ debug: false,
7
+ sessionWindowMs: 30 * 60 * 1000, // 30 minutes
8
+ buttonSelector: '.elysium-payment-button',
9
+ autoTrack: true,
10
+ productPathRegex: null,
11
+ cookieDomain: undefined,
12
+ customParams: [],
13
+ gateways: {
14
+ stripe: {
15
+ enabled: true,
16
+ publishableKey: '',
17
+ },
18
+ razorpay: {
19
+ enabled: true,
20
+ keyId: '',
21
+ },
22
+ paypal: {
23
+ enabled: true,
24
+ clientId: '',
25
+ },
26
+ },
27
+ };
28
+
29
+ const SECRET_KEY_PATTERNS = [
30
+ /^sk_live_[0-9a-zA-Z]+/i,
31
+ /^sk_test_[0-9a-zA-Z]+/i,
32
+ /^sec_[0-9a-zA-Z]+/i,
33
+ /secret/i,
34
+ ];
35
+
36
+ /**
37
+ * Audit configuration for secret key exposure and validate values.
38
+ */
39
+ export function validateAndSanitizeConfig(userConfig = {}) {
40
+ const sanitized = { ...DEFAULT_CONFIG, ...userConfig };
41
+
42
+ // Check for exposed secret keys in common mistake fields
43
+ const suspiciousKeys = ['apiSecret', 'secretKey', 'clientSecret', 'privateKey'];
44
+ for (const key of suspiciousKeys) {
45
+ if (sanitized[key]) {
46
+ logger.error(
47
+ `SECURITY WARNING: "${key}" was found in browser configuration! Secret keys must NEVER be exposed in client-side code. This key has been stripped for your security.`
48
+ );
49
+ delete sanitized[key];
50
+ }
51
+ }
52
+
53
+ // Check gateway credentials
54
+ if (sanitized.gateways && typeof sanitized.gateways === 'object') {
55
+ sanitized.gateways = { ...DEFAULT_CONFIG.gateways, ...sanitized.gateways };
56
+
57
+ for (const [gwName, gwConfig] of Object.entries(sanitized.gateways)) {
58
+ if (!gwConfig || typeof gwConfig !== 'object') continue;
59
+
60
+ for (const [k, v] of Object.entries(gwConfig)) {
61
+ if (typeof v === 'string') {
62
+ for (const pattern of SECRET_KEY_PATTERNS) {
63
+ if (pattern.test(k) || pattern.test(v)) {
64
+ logger.error(
65
+ `SECURITY WARNING: Possible secret credential detected in gateway "${gwName}.${k}". Secret keys must remain on the backend!`
66
+ );
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // Ensure base URL has no trailing slashes
75
+ if (sanitized.apiBaseUrl && typeof sanitized.apiBaseUrl === 'string') {
76
+ sanitized.apiBaseUrl = sanitized.apiBaseUrl.replace(/\/+$/, '');
77
+ }
78
+
79
+ // Ensure sessionWindowMs is valid positive integer
80
+ if (typeof sanitized.sessionWindowMs !== 'number' || sanitized.sessionWindowMs <= 0) {
81
+ sanitized.sessionWindowMs = DEFAULT_CONFIG.sessionWindowMs;
82
+ }
83
+
84
+ return sanitized;
85
+ }
@@ -0,0 +1,128 @@
1
+ import { getConfig } from '../config/index.js';
2
+ import { gatewayRegistry } from '../gateways/GatewayRegistry.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ let isListening = false;
6
+ let observer = null;
7
+ const debouncedButtons = new WeakMap();
8
+ const DEBOUNCE_DELAY_MS = 1500;
9
+
10
+ /**
11
+ * Check if an element matches the configured payment button selectors
12
+ */
13
+ export function isPaymentButton(target, selector) {
14
+ if (!target || typeof target.closest !== 'function') return null;
15
+ return target.closest(selector);
16
+ }
17
+
18
+ /**
19
+ * Handle a detected payment button click
20
+ */
21
+ export async function handleButtonClickEvent(event, customSelector = null) {
22
+ const config = getConfig();
23
+ const selector = customSelector || config.buttonSelector || '.elysium-payment-button';
24
+
25
+ const button = isPaymentButton(event.target, selector);
26
+ if (!button) return;
27
+
28
+ // Prevent duplicate accidental double-clicks within debounce window
29
+ const lastClick = debouncedButtons.get(button);
30
+ const now = Date.now();
31
+ if (lastClick && now - lastClick < DEBOUNCE_DELAY_MS) {
32
+ logger.debug('Ignoring rapid double-click on payment button');
33
+ return;
34
+ }
35
+ debouncedButtons.set(button, now);
36
+
37
+ // Extract contextual information from the button and any surrounding form
38
+ const context = {
39
+ buttonId: button.id || null,
40
+ buttonClasses: button.className || null,
41
+ buttonText: button.innerText?.trim() || null,
42
+ gatewayAttribute: button.dataset?.gateway || null,
43
+ amount: button.dataset?.amount ? Number.parseFloat(button.dataset.amount) : null,
44
+ currency: button.dataset?.currency || null,
45
+ productId: button.dataset?.productId || null,
46
+ };
47
+
48
+ const form = button.closest('form');
49
+ if (form) {
50
+ context.formId = form.id || null;
51
+ context.formAction = form.action || null;
52
+ }
53
+
54
+ // Resolve associated payment gateway
55
+ const gateway = gatewayRegistry.resolveForButton(button);
56
+ logger.info(`Detected payment button interaction for gateway [${gateway.name}]:`, context);
57
+
58
+ // Trigger gateway attempt handler (NOTE: Button click is intent/attempt, NOT payment proof!)
59
+ try {
60
+ await gateway.handleButtonClick(button, context);
61
+ } catch (err) {
62
+ logger.error(`Error in ${gateway.name} handleButtonClick:`, err);
63
+ }
64
+
65
+ if (typeof window !== 'undefined') {
66
+ window.dispatchEvent(
67
+ new CustomEvent('elysium:payment_attempt', {
68
+ detail: { gateway: gateway.name, button, context },
69
+ })
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Initialize payment button detection with delegated event listening and MutationObserver
76
+ */
77
+ export function initPaymentButtonDetection(customSelector = null) {
78
+ if (typeof document === 'undefined') return () => {};
79
+
80
+ const config = getConfig();
81
+ const selector = customSelector || config.buttonSelector || '.elysium-payment-button';
82
+
83
+ if (!isListening) {
84
+ // Delegated click listener handles dynamically mounted buttons automatically
85
+ const clickHandler = (event) => handleButtonClickEvent(event, selector);
86
+ document.addEventListener('click', clickHandler, { capture: true, passive: true });
87
+ isListening = true;
88
+
89
+ // MutationObserver to detect when payment buttons appear in DOM
90
+ if (typeof MutationObserver !== 'undefined') {
91
+ observer = new MutationObserver((mutations) => {
92
+ for (const mutation of mutations) {
93
+ for (const node of mutation.addedNodes) {
94
+ if (node.nodeType === Node.ELEMENT_NODE) {
95
+ const matched = node.matches?.(selector) ? [node] : Array.from(node.querySelectorAll?.(selector) || []);
96
+ if (matched.length > 0) {
97
+ logger.debug(`MutationObserver detected ${matched.length} new payment button(s) dynamically`);
98
+ matched.forEach((btn) => {
99
+ window.dispatchEvent(
100
+ new CustomEvent('elysium:button_detected', { detail: { button: btn } })
101
+ );
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+ });
108
+
109
+ observer.observe(document.body || document.documentElement, {
110
+ childList: true,
111
+ subtree: true,
112
+ });
113
+ }
114
+
115
+ logger.debug(`Payment button detector initialized with selector: "${selector}"`);
116
+
117
+ return () => {
118
+ document.removeEventListener('click', clickHandler, { capture: true });
119
+ if (observer) {
120
+ observer.disconnect();
121
+ observer = null;
122
+ }
123
+ isListening = false;
124
+ };
125
+ }
126
+
127
+ return () => {};
128
+ }
@@ -0,0 +1,75 @@
1
+ import { StripeGateway } from './StripeGateway.js';
2
+ import { RazorpayGateway } from './RazorpayGateway.js';
3
+ import { PayPalGateway } from './PayPalGateway.js';
4
+ import { GenericGateway } from './GenericGateway.js';
5
+ import { logger } from '../utils/logger.js';
6
+
7
+ export class GatewayRegistry {
8
+ constructor() {
9
+ this.gateways = new Map();
10
+ this.initDefaultGateways();
11
+ }
12
+
13
+ initDefaultGateways() {
14
+ this.register('stripe', new StripeGateway());
15
+ this.register('razorpay', new RazorpayGateway());
16
+ this.register('paypal', new PayPalGateway());
17
+ this.generic = new GenericGateway('generic');
18
+ }
19
+
20
+ register(name, gatewayInstance) {
21
+ if (!name || typeof name !== 'string') {
22
+ throw new Error('Gateway registration requires a string name');
23
+ }
24
+ const key = name.toLowerCase();
25
+ this.gateways.set(key, gatewayInstance);
26
+ logger.debug(`Registered payment gateway: ${key}`);
27
+ return gatewayInstance;
28
+ }
29
+
30
+ get(name) {
31
+ if (!name) return null;
32
+ return this.gateways.get(name.toLowerCase()) || null;
33
+ }
34
+
35
+ getAll() {
36
+ return Array.from(this.gateways.values());
37
+ }
38
+
39
+ /**
40
+ * Find the matching gateway for a button element or context
41
+ */
42
+ resolveForButton(buttonElement) {
43
+ if (!buttonElement) return this.generic;
44
+
45
+ // 1. Direct data-gateway attribute check
46
+ const explicitGatewayName = buttonElement.dataset?.gateway;
47
+ if (explicitGatewayName && this.gateways.has(explicitGatewayName.toLowerCase())) {
48
+ return this.gateways.get(explicitGatewayName.toLowerCase());
49
+ }
50
+
51
+ // 2. Iterate registered gateways and ask canHandle()
52
+ for (const gateway of this.gateways.values()) {
53
+ if (gateway.canHandle(buttonElement)) {
54
+ return gateway;
55
+ }
56
+ }
57
+
58
+ // 3. Fallback to generic gateway
59
+ return this.generic;
60
+ }
61
+
62
+ /**
63
+ * Reconfigure all registered gateways with latest config settings
64
+ */
65
+ configureAll(gatewaysConfig = {}) {
66
+ for (const [name, config] of Object.entries(gatewaysConfig)) {
67
+ const gw = this.get(name);
68
+ if (gw) {
69
+ gw.init(config);
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ export const gatewayRegistry = new GatewayRegistry();
@@ -0,0 +1,12 @@
1
+ import { PaymentGateway } from './PaymentGateway.js';
2
+
3
+ export class GenericGateway extends PaymentGateway {
4
+ constructor(name = 'generic', config = {}) {
5
+ super(name, config);
6
+ }
7
+
8
+ canHandle(buttonOrContext) {
9
+ if (super.canHandle(buttonOrContext)) return true;
10
+ return true; // Generic acts as ultimate fallback
11
+ }
12
+ }
@@ -0,0 +1,83 @@
1
+ import { PaymentGateway } from './PaymentGateway.js';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ export class PayPalGateway extends PaymentGateway {
5
+ constructor(config = {}) {
6
+ super('paypal', config);
7
+ }
8
+
9
+ canHandle(buttonOrContext) {
10
+ if (super.canHandle(buttonOrContext)) return true;
11
+
12
+ if (typeof HTMLElement !== 'undefined' && buttonOrContext instanceof HTMLElement) {
13
+ const text = buttonOrContext.innerText?.toLowerCase() || '';
14
+ const id = buttonOrContext.id?.toLowerCase() || '';
15
+ const cls = buttonOrContext.className?.toLowerCase() || '';
16
+ if (text.includes('paypal') || id.includes('paypal') || cls.includes('paypal')) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
22
+
23
+ /**
24
+ * Wrap options passed to paypal.Buttons(...)
25
+ */
26
+ wrapButtons(options = {}) {
27
+ const originalCreateOrder = options.createOrder;
28
+ const originalOnApprove = options.onApprove;
29
+ const originalOnCancel = options.onCancel;
30
+ const originalOnError = options.onError;
31
+
32
+ return {
33
+ ...options,
34
+ createOrder: async (data, actions) => {
35
+ this.recordPaymentAttempt({ stage: 'createOrder', data });
36
+ if (typeof originalCreateOrder === 'function') {
37
+ return originalCreateOrder(data, actions);
38
+ }
39
+ },
40
+ onApprove: async (data, actions) => {
41
+ logger.info('[PayPal] Payment approved by buyer:', data);
42
+
43
+ this.recordPaymentSuccess({
44
+ transactionId: data.orderID,
45
+ orderId: data.orderID,
46
+ payerId: data.payerID,
47
+ paymentId: data.paymentID,
48
+ subscriptionId: data.subscriptionID,
49
+ source: 'paypal_buttons',
50
+ });
51
+
52
+ if (typeof originalOnApprove === 'function') {
53
+ return originalOnApprove(data, actions);
54
+ }
55
+ },
56
+ onCancel: (data) => {
57
+ logger.debug('[PayPal] Payment cancelled by buyer:', data);
58
+
59
+ this.recordPaymentFailure({
60
+ code: 'paypal_cancelled',
61
+ message: 'Buyer cancelled the PayPal flow',
62
+ orderId: data?.orderID,
63
+ });
64
+
65
+ if (typeof originalOnCancel === 'function') {
66
+ return originalOnCancel(data);
67
+ }
68
+ },
69
+ onError: (err) => {
70
+ logger.error('[PayPal] Payment error encountered:', err);
71
+
72
+ this.recordPaymentFailure({
73
+ code: 'paypal_error',
74
+ message: err?.message || String(err),
75
+ });
76
+
77
+ if (typeof originalOnError === 'function') {
78
+ return originalOnError(err);
79
+ }
80
+ },
81
+ };
82
+ }
83
+ }
@@ -0,0 +1,173 @@
1
+ import { getVisitorId } from '../signals/visitor.js';
2
+ import { request } from '../signals/transport.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Base PaymentGateway class defining standard lifecycle and interface
7
+ */
8
+ export class PaymentGateway {
9
+ constructor(name, config = {}) {
10
+ if (!name) throw new Error('Gateway must have a unique name');
11
+ this.name = name;
12
+ this.config = config;
13
+ this.listeners = new Map();
14
+ }
15
+
16
+ /**
17
+ * Lifecycle initialize
18
+ */
19
+ init(gatewayConfig = {}) {
20
+ this.config = { ...this.config, ...gatewayConfig };
21
+ logger.debug(`[Gateway:${this.name}] Initialized with config:`, this.config);
22
+ }
23
+
24
+ /**
25
+ * Determine if this gateway matches the clicked button or context
26
+ */
27
+ canHandle(buttonOrContext) {
28
+ if (!buttonOrContext) return false;
29
+
30
+ // Check DOM element data attributes
31
+ if (typeof HTMLElement !== 'undefined' && buttonOrContext instanceof HTMLElement) {
32
+ const dataGateway = buttonOrContext.dataset?.gateway;
33
+ if (dataGateway && dataGateway.toLowerCase() === this.name.toLowerCase()) {
34
+ return true;
35
+ }
36
+ // Check class name
37
+ if (buttonOrContext.classList.contains(`elysium-gateway-${this.name}`)) {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ // Check context object
43
+ if (typeof buttonOrContext === 'object' && buttonOrContext.gateway) {
44
+ return buttonOrContext.gateway.toLowerCase() === this.name.toLowerCase();
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Invoked when an elysium payment button associated with this gateway is clicked.
52
+ * NOTE: A button click is only a payment INTENT/ATTEMPT, never proof of payment!
53
+ */
54
+ async handleButtonClick(button, context = {}) {
55
+ logger.info(`[Gateway:${this.name}] Payment button interaction detected:`, {
56
+ gateway: this.name,
57
+ buttonText: button?.innerText?.trim(),
58
+ context,
59
+ });
60
+
61
+ return this.recordPaymentAttempt({
62
+ gateway: this.name,
63
+ buttonId: button?.id || null,
64
+ buttonClass: button?.className || null,
65
+ ...context,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Record payment attempt / checkout start
71
+ */
72
+ async recordPaymentAttempt(details = {}) {
73
+ const payload = {
74
+ visitorId: getVisitorId(),
75
+ gateway: this.name,
76
+ status: 'attempted',
77
+ timestamp: new Date().toISOString(),
78
+ metadata: details,
79
+ };
80
+
81
+ this.emit('attempt', payload);
82
+
83
+ return request('/v1/payment/attempt', {
84
+ method: 'POST',
85
+ body: payload,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Record verified payment success with transaction identifiers
91
+ */
92
+ async recordPaymentSuccess(paymentData = {}) {
93
+ const payload = {
94
+ visitorId: getVisitorId(),
95
+ gateway: this.name,
96
+ status: 'succeeded',
97
+ transactionId: paymentData.transactionId || paymentData.id || null,
98
+ orderId: paymentData.orderId || null,
99
+ amount: paymentData.amount || null,
100
+ currency: paymentData.currency || 'USD',
101
+ timestamp: new Date().toISOString(),
102
+ ...paymentData,
103
+ metadata: paymentData,
104
+ };
105
+
106
+ logger.info(`[Gateway:${this.name}] Payment succeeded:`, payload);
107
+ this.emit('success', payload);
108
+
109
+ if (typeof window !== 'undefined') {
110
+ window.dispatchEvent(
111
+ new CustomEvent('elysium:payment_success', { detail: payload })
112
+ );
113
+ }
114
+
115
+ return request('/v1/payment/success', {
116
+ method: 'POST',
117
+ body: payload,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Record payment failure or cancellation
123
+ */
124
+ async recordPaymentFailure(errorData = {}) {
125
+ const payload = {
126
+ visitorId: getVisitorId(),
127
+ gateway: this.name,
128
+ status: 'failed',
129
+ errorCode: errorData.code || null,
130
+ errorMessage: errorData.message || 'Payment failed',
131
+ timestamp: new Date().toISOString(),
132
+ metadata: errorData,
133
+ };
134
+
135
+ logger.warn(`[Gateway:${this.name}] Payment failed:`, payload);
136
+ this.emit('failure', payload);
137
+
138
+ if (typeof window !== 'undefined') {
139
+ window.dispatchEvent(
140
+ new CustomEvent('elysium:payment_failure', { detail: payload })
141
+ );
142
+ }
143
+
144
+ return request('/v1/payment/failure', {
145
+ method: 'POST',
146
+ body: payload,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Event emitter helpers
152
+ */
153
+ on(event, callback) {
154
+ if (!this.listeners.has(event)) {
155
+ this.listeners.set(event, new Set());
156
+ }
157
+ this.listeners.get(event).add(callback);
158
+ return () => this.listeners.get(event)?.delete(callback);
159
+ }
160
+
161
+ emit(event, data) {
162
+ const callbacks = this.listeners.get(event);
163
+ if (callbacks) {
164
+ callbacks.forEach((cb) => {
165
+ try {
166
+ cb(data);
167
+ } catch (err) {
168
+ logger.error(`Error in ${this.name} ${event} listener:`, err);
169
+ }
170
+ });
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,88 @@
1
+ import { PaymentGateway } from './PaymentGateway.js';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ export class RazorpayGateway extends PaymentGateway {
5
+ constructor(config = {}) {
6
+ super('razorpay', config);
7
+ }
8
+
9
+ canHandle(buttonOrContext) {
10
+ if (super.canHandle(buttonOrContext)) return true;
11
+
12
+ if (typeof HTMLElement !== 'undefined' && buttonOrContext instanceof HTMLElement) {
13
+ const text = buttonOrContext.innerText?.toLowerCase() || '';
14
+ const id = buttonOrContext.id?.toLowerCase() || '';
15
+ const cls = buttonOrContext.className?.toLowerCase() || '';
16
+ if (text.includes('razorpay') || id.includes('razorpay') || cls.includes('razorpay')) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
22
+
23
+ /**
24
+ * Wrap Razorpay checkout options to automatically intercept payment success/dismissal
25
+ */
26
+ wrapOptions(options = {}) {
27
+ const originalHandler = options.handler;
28
+
29
+ const wrappedOptions = {
30
+ ...options,
31
+ handler: (response) => {
32
+ logger.info('[Razorpay] Payment successful response received:', response);
33
+
34
+ this.recordPaymentSuccess({
35
+ transactionId: response.razorpay_payment_id,
36
+ orderId: response.razorpay_order_id,
37
+ signature: response.razorpay_signature,
38
+ amount: options.amount ? options.amount / 100 : undefined,
39
+ currency: options.currency || 'INR',
40
+ });
41
+
42
+ if (typeof originalHandler === 'function') {
43
+ return originalHandler(response);
44
+ }
45
+ },
46
+ };
47
+
48
+ // Modal dismiss handler
49
+ const originalModal = options.modal || {};
50
+ const originalOnDismiss = originalModal.ondismiss;
51
+ wrappedOptions.modal = {
52
+ ...originalModal,
53
+ ondismiss: () => {
54
+ logger.debug('[Razorpay] Checkout modal dismissed by user');
55
+ this.recordPaymentFailure({
56
+ code: 'modal_dismissed',
57
+ message: 'User closed the Razorpay checkout modal before completing payment',
58
+ });
59
+ if (typeof originalOnDismiss === 'function') {
60
+ return originalOnDismiss();
61
+ }
62
+ },
63
+ };
64
+
65
+ return wrappedOptions;
66
+ }
67
+
68
+ /**
69
+ * Instrument a Razorpay instance to listen for payment.failed events
70
+ */
71
+ wrapInstance(rzpInstance) {
72
+ if (!rzpInstance || typeof rzpInstance.on !== 'function') return rzpInstance;
73
+
74
+ rzpInstance.on('payment.failed', (response) => {
75
+ logger.warn('[Razorpay] Payment failed event caught:', response);
76
+ this.recordPaymentFailure({
77
+ code: response.error?.code || 'payment_failed',
78
+ message: response.error?.description || 'Razorpay payment failed',
79
+ reason: response.error?.reason,
80
+ step: response.error?.step,
81
+ orderId: response.error?.metadata?.order_id,
82
+ transactionId: response.error?.metadata?.payment_id,
83
+ });
84
+ });
85
+
86
+ return rzpInstance;
87
+ }
88
+ }