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,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
|
+
}
|