quickpos 1.0.915 → 1.0.917
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/lib/paynkolay.js +193 -0
- package/lib/paytr.js +35 -13
- package/lib/paywant.js +137 -0
- package/package.json +1 -1
- package/readme.md +4 -0
package/lib/paynkolay.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const axios = require('axios');
|
|
3
|
+
|
|
4
|
+
class Paynkolay {
|
|
5
|
+
/**
|
|
6
|
+
* @param {Object} config
|
|
7
|
+
* @param {string} config.sx - SX parametresi (Panelden alınır)
|
|
8
|
+
* @param {string} config.merchantSecretKey - Gizli Anahtar
|
|
9
|
+
* @param {string} config.merchantNo - Mağaza Numarası (Callback doğrulama için)
|
|
10
|
+
* @param {boolean} [config.isTest=true] - Test ortamı mı?
|
|
11
|
+
*/
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.sx = config.sx;
|
|
14
|
+
this.merchantSecretKey = config.merchantSecretKey;
|
|
15
|
+
this.merchantNo = config.merchantNo;
|
|
16
|
+
|
|
17
|
+
// Postman'deki host bilgisine göre
|
|
18
|
+
this.baseUrl = config.isTest
|
|
19
|
+
? 'https://paynkolaytest.nkolayislem.com.tr'
|
|
20
|
+
: 'https://paynkolay.nkolayislem.com.tr';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Link ile Ödeme Oluşturma (/Vpos/by-link-create)
|
|
25
|
+
* Postman'deki 'Link Oluştur' script'ine göre hazırlanmıştır.
|
|
26
|
+
*/
|
|
27
|
+
async createPaymentLink(params) {
|
|
28
|
+
try {
|
|
29
|
+
const endpoint = '/Vpos/by-link-create';
|
|
30
|
+
|
|
31
|
+
// Zorunlu alanlar için varsayılan değerler ve kontroller
|
|
32
|
+
const rnd = params.rnd || Date.now().toString();
|
|
33
|
+
const clientRefCode = params.clientRefCode || ('REF-' + rnd);
|
|
34
|
+
const customerKey = params.customerKey || ""; // Kart saklama yoksa boş
|
|
35
|
+
const amount = parseFloat(params.amount).toFixed(2); // 1000.00 formatı
|
|
36
|
+
|
|
37
|
+
// 1. HASH OLUŞTURMA (Postman Script'ine göre)
|
|
38
|
+
// Sıralama: sx|clientRefCode|amount|successUrl|failUrl|rnd|customerKey|merchantSecretKey
|
|
39
|
+
const hashString = [
|
|
40
|
+
this.sx,
|
|
41
|
+
clientRefCode,
|
|
42
|
+
amount,
|
|
43
|
+
params.successUrl,
|
|
44
|
+
params.failUrl,
|
|
45
|
+
rnd,
|
|
46
|
+
customerKey,
|
|
47
|
+
this.merchantSecretKey
|
|
48
|
+
].join("|");
|
|
49
|
+
|
|
50
|
+
const hashDatav2 = this.generateHash(hashString);
|
|
51
|
+
|
|
52
|
+
// 2. FORM DATA HAZIRLAMA
|
|
53
|
+
const data = new URLSearchParams();
|
|
54
|
+
data.append('sx', this.sx);
|
|
55
|
+
data.append('clientRefCode', clientRefCode);
|
|
56
|
+
data.append('amount', amount);
|
|
57
|
+
data.append('successUrl', params.successUrl);
|
|
58
|
+
data.append('failUrl', params.failUrl);
|
|
59
|
+
data.append('rnd', rnd);
|
|
60
|
+
data.append('hashDatav2', hashDatav2);
|
|
61
|
+
data.append('use3D', params.use3D || 'true');
|
|
62
|
+
data.append('currencyCode', params.currencyCode || '949'); // 949 = TL
|
|
63
|
+
data.append('transactionType', 'SALES');
|
|
64
|
+
|
|
65
|
+
// Opsiyonel (Link Detayları)
|
|
66
|
+
if (params.instalments) data.append('instalments', params.instalments);
|
|
67
|
+
if (params.second) data.append('second', params.second); // Link süresi (saniye)
|
|
68
|
+
if (params.cardHolderIP) data.append('cardHolderIP', params.cardHolderIP);
|
|
69
|
+
|
|
70
|
+
// Link Sayfası Görünen Bilgiler
|
|
71
|
+
data.append('detail', 'true');
|
|
72
|
+
data.append('inputNamesurname', params.inputNamesurname || '');
|
|
73
|
+
data.append('inputDescription', params.inputDescription || '');
|
|
74
|
+
data.append('inputEmail', params.inputEmail || '');
|
|
75
|
+
data.append('inputAddress', params.inputAddress || '');
|
|
76
|
+
data.append('inputTckn', params.inputTckn || '');
|
|
77
|
+
data.append('inputPhone', params.inputPhone || '');
|
|
78
|
+
|
|
79
|
+
// 3. İSTEK GÖNDERME
|
|
80
|
+
const response = await axios.post(`${this.baseUrl}${endpoint}`, data, {
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return response.data;
|
|
87
|
+
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(`Paynkolay Link Oluşturma Hatası: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Link Silme (/Vpos/by-link-url-remove)
|
|
95
|
+
*/
|
|
96
|
+
async removePaymentLink(qParam) {
|
|
97
|
+
try {
|
|
98
|
+
const endpoint = '/Vpos/by-link-url-remove';
|
|
99
|
+
|
|
100
|
+
// HASH OLUŞTURMA (Link Sil Script'ine göre)
|
|
101
|
+
// Sıralama: sx|q|merchantSecretKey
|
|
102
|
+
const hashString = [
|
|
103
|
+
this.sx,
|
|
104
|
+
qParam, // Bu değer genelde referans kodudur
|
|
105
|
+
this.merchantSecretKey
|
|
106
|
+
].join("|");
|
|
107
|
+
|
|
108
|
+
const hashDatav2 = this.generateHash(hashString);
|
|
109
|
+
|
|
110
|
+
const data = new URLSearchParams();
|
|
111
|
+
data.append('sx', this.sx);
|
|
112
|
+
data.append('q', qParam);
|
|
113
|
+
data.append('hashDatav2', hashDatav2);
|
|
114
|
+
|
|
115
|
+
const response = await axios.post(`${this.baseUrl}${endpoint}`, data, {
|
|
116
|
+
headers: {
|
|
117
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return response.data;
|
|
122
|
+
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(`Paynkolay Link Silme Hatası: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Callback (Bildirim) Doğrulama
|
|
130
|
+
* Ödeme sonrası Paynkolay'ın successUrl'e gönderdiği veriyi doğrular.
|
|
131
|
+
*/
|
|
132
|
+
handleCallback(params) {
|
|
133
|
+
const {
|
|
134
|
+
RESPONSE_CODE,
|
|
135
|
+
MERCHANT_NO,
|
|
136
|
+
REFERENCE_CODE,
|
|
137
|
+
AUTH_CODE,
|
|
138
|
+
USE_3D,
|
|
139
|
+
RND,
|
|
140
|
+
INSTALLMENT,
|
|
141
|
+
AUTHORIZATION_AMOUNT,
|
|
142
|
+
CURRENCY_CODE,
|
|
143
|
+
hashDataV2
|
|
144
|
+
} = params;
|
|
145
|
+
|
|
146
|
+
// 1. İşlem Başarılı mı kontrolü
|
|
147
|
+
if (RESPONSE_CODE !== "2" && RESPONSE_CODE !== 2) {
|
|
148
|
+
return { status: false, message: 'İşlem başarısız (RESPONSE_CODE != 2)' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Hash Hesaplama (Dokümantasyondaki 'Response Hash Testi' sırası)
|
|
152
|
+
// MERCHANT_NO | REFERENCE_CODE | AUTH_CODE | RESPONSE_CODE | USE_3D | RND | INSTALLMENT | AUTHORIZATION_AMOUNT | CURRENCY_CODE | MERCHANT_SECRET_KEY
|
|
153
|
+
|
|
154
|
+
const hashString = [
|
|
155
|
+
MERCHANT_NO,
|
|
156
|
+
REFERENCE_CODE,
|
|
157
|
+
AUTH_CODE,
|
|
158
|
+
RESPONSE_CODE,
|
|
159
|
+
USE_3D, // Gelen değer string "true" veya "false" olabilir, olduğu gibi kullanılmalı
|
|
160
|
+
RND,
|
|
161
|
+
INSTALLMENT,
|
|
162
|
+
AUTHORIZATION_AMOUNT,
|
|
163
|
+
CURRENCY_CODE,
|
|
164
|
+
this.merchantSecretKey
|
|
165
|
+
].join("|");
|
|
166
|
+
|
|
167
|
+
const calculatedHash = this.generateHash(hashString);
|
|
168
|
+
|
|
169
|
+
if (calculatedHash === hashDataV2) {
|
|
170
|
+
return {
|
|
171
|
+
status: 'success',
|
|
172
|
+
message: 'Doğrulama Başarılı',
|
|
173
|
+
amount: AUTHORIZATION_AMOUNT,
|
|
174
|
+
orderId: params.CLIENT_REFERENCE_CODE || REFERENCE_CODE
|
|
175
|
+
};
|
|
176
|
+
} else {
|
|
177
|
+
console.error("Hash String (Local):", hashString);
|
|
178
|
+
console.error("Calculated:", calculatedHash);
|
|
179
|
+
console.error("Received:", hashDataV2);
|
|
180
|
+
return { status: false, message: 'Hash uyuşmazlığı (Güvenlik Hatası)' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* SHA-512 Base64 Helper
|
|
186
|
+
*/
|
|
187
|
+
generateHash(str) {
|
|
188
|
+
// UTF-8 encoding önemlidir (CryptoJS.SHA512 davranışı)
|
|
189
|
+
return crypto.createHash('sha512').update(str, 'utf8').digest('base64');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = Paynkolay;
|
package/lib/paytr.js
CHANGED
|
@@ -170,7 +170,20 @@ class PayTR {
|
|
|
170
170
|
const maxInstallment = params.maxInstallment || '0';
|
|
171
171
|
const paymentType = params.paymentType || 'card';
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
let rawString;
|
|
174
|
+
|
|
175
|
+
// DÜZELTME BURADA YAPILDI:
|
|
176
|
+
if (paymentType === 'eft') {
|
|
177
|
+
// Python örneğindeki EFT Hash yapısı:
|
|
178
|
+
// merchant_id + user_ip + merchant_oid + email + payment_amount + payment_type + test_mode
|
|
179
|
+
rawString = `${this.merchantId}${userIp}${merchantOid}${email}${paymentAmount}${paymentType}${this.testMode}`;
|
|
180
|
+
} else {
|
|
181
|
+
// Standart Kredi Kartı Hash yapısı:
|
|
182
|
+
// merchant_id + user_ip + merchant_oid + email + payment_amount + user_basket + no_installment + max_installment + currency + test_mode
|
|
183
|
+
rawString = `${this.merchantId}${userIp}${merchantOid}${email}${paymentAmount}${userBasket}${noInstallment}${maxInstallment}${currency}${this.testMode}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// generateToken fonksiyonu sonuna merchantSalt ekleyip şifreler
|
|
174
187
|
const paytrToken = this.generateToken(rawString);
|
|
175
188
|
|
|
176
189
|
const formData = new URLSearchParams();
|
|
@@ -180,22 +193,29 @@ class PayTR {
|
|
|
180
193
|
formData.append('email', email);
|
|
181
194
|
formData.append('payment_amount', paymentAmount);
|
|
182
195
|
formData.append('paytr_token', paytrToken);
|
|
183
|
-
formData.append('user_basket', userBasket);
|
|
184
196
|
formData.append('debug_on', this.debugOn);
|
|
185
|
-
formData.append('no_installment', noInstallment);
|
|
186
|
-
formData.append('max_installment', maxInstallment);
|
|
187
|
-
formData.append('user_name', params.userName);
|
|
188
|
-
formData.append('user_address', params.userAddress);
|
|
189
|
-
formData.append('user_phone', params.userPhone);
|
|
190
|
-
formData.append('merchant_ok_url', params.merchantOkUrl);
|
|
191
|
-
formData.append('merchant_fail_url', params.merchantFailUrl);
|
|
192
|
-
formData.append('timeout_limit', params.timeoutLimit || '30');
|
|
193
|
-
formData.append('currency', currency);
|
|
194
197
|
formData.append('test_mode', this.testMode);
|
|
195
198
|
formData.append('payment_type', paymentType);
|
|
199
|
+
|
|
200
|
+
// timeout_limit parametresi (Python kodunda vardı, ekleyelim)
|
|
201
|
+
formData.append('timeout_limit', params.timeoutLimit || '30');
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
if (
|
|
203
|
+
// EFT değilse diğer parametreleri de ekleyelim
|
|
204
|
+
if (paymentType !== 'eft') {
|
|
205
|
+
formData.append('user_basket', userBasket);
|
|
206
|
+
formData.append('no_installment', noInstallment);
|
|
207
|
+
formData.append('max_installment', maxInstallment);
|
|
208
|
+
formData.append('user_name', params.userName);
|
|
209
|
+
formData.append('user_address', params.userAddress);
|
|
210
|
+
formData.append('user_phone', params.userPhone);
|
|
211
|
+
formData.append('merchant_ok_url', params.merchantOkUrl);
|
|
212
|
+
formData.append('merchant_fail_url', params.merchantFailUrl);
|
|
213
|
+
formData.append('currency', currency);
|
|
214
|
+
if (params.lang) formData.append('lang', params.lang);
|
|
215
|
+
} else {
|
|
216
|
+
// EFT ise banka parametresi varsa ekle
|
|
217
|
+
if (params.bank) formData.append('bank', params.bank);
|
|
218
|
+
}
|
|
199
219
|
|
|
200
220
|
const response = await axios({
|
|
201
221
|
method: 'POST',
|
|
@@ -208,8 +228,10 @@ class PayTR {
|
|
|
208
228
|
if (result.status === 'success') {
|
|
209
229
|
return { status: 'success', token: result.token };
|
|
210
230
|
} else {
|
|
231
|
+
// Hata detayını daha net görelim
|
|
211
232
|
throw new Error(result.reason || 'PayTR Token alma hatası');
|
|
212
233
|
}
|
|
234
|
+
|
|
213
235
|
} catch (error) {
|
|
214
236
|
if (error.response) throw new Error(`PayTR API Error: ${error.response.data.reason || error.response.statusText}`);
|
|
215
237
|
throw error;
|
package/lib/paywant.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const axios = require('axios');
|
|
3
|
+
|
|
4
|
+
class Paywant {
|
|
5
|
+
/**
|
|
6
|
+
* Paywant Kurucu Metodu
|
|
7
|
+
* @param {Object} config
|
|
8
|
+
* @param {string} config.apiKey - Paywant API Anahtarı
|
|
9
|
+
* @param {string} config.apiSecret - Paywant Gizli Anahtar
|
|
10
|
+
*/
|
|
11
|
+
constructor(config) {
|
|
12
|
+
if (!config.apiKey || !config.apiSecret) {
|
|
13
|
+
throw new Error('Paywant: apiKey ve apiSecret zorunludur.');
|
|
14
|
+
}
|
|
15
|
+
this.apiKey = config.apiKey;
|
|
16
|
+
this.apiSecret = config.apiSecret;
|
|
17
|
+
this.baseUrl = 'https://secure.paywant.com';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ödeme Linki Oluşturma (/payment/token)
|
|
22
|
+
* @param {Object} params
|
|
23
|
+
*/
|
|
24
|
+
async createPayment(params) {
|
|
25
|
+
try {
|
|
26
|
+
// Zorunlu alan kontrolü
|
|
27
|
+
const required = ['userID', 'userEmail', 'userAccountName', 'userIp', 'productData'];
|
|
28
|
+
for (const field of required) {
|
|
29
|
+
if (!params[field]) throw new Error(`Paywant: ${field} alanı zorunludur.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 1. Hash Oluşturma (Dokümantasyona Göre)
|
|
33
|
+
// Sıralama: userName|userEmail|userID + ApiKey
|
|
34
|
+
const hashString = `${params.userAccountName}|${params.userEmail}|${params.userID}${this.apiKey}`;
|
|
35
|
+
const hash = this.generateHash(hashString);
|
|
36
|
+
|
|
37
|
+
// 2. API'ye Gönderilecek Veri
|
|
38
|
+
const payload = {
|
|
39
|
+
apiKey: this.apiKey,
|
|
40
|
+
userAccountName: params.userAccountName,
|
|
41
|
+
userEmail: params.userEmail,
|
|
42
|
+
userID: parseInt(params.userID), // int64 olmalı
|
|
43
|
+
userIPAddress: params.userIp,
|
|
44
|
+
hash: hash,
|
|
45
|
+
proApi: true, // Ürün verisini biz gönderiyoruz
|
|
46
|
+
productData: {
|
|
47
|
+
name: params.productData.name, // Ürün adı
|
|
48
|
+
amount: parseFloat(params.productData.amount), // Tutar (100.00 gibi)
|
|
49
|
+
extraData: params.productData.extraData || '', // Sipariş No vb.
|
|
50
|
+
paymentChannel: params.productData.paymentChannel || '0', // 0 = Hepsi
|
|
51
|
+
commissionType: params.productData.commissionType || '1' // 1: Müşteri, 2: Mağaza öder
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// 3. İsteği Gönder
|
|
56
|
+
const response = await axios.post(`${this.baseUrl}/payment/token`, payload, {
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/json'
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = response.data;
|
|
63
|
+
|
|
64
|
+
if (result.status === true) {
|
|
65
|
+
// Başarılı ise ödeme linkini döndür
|
|
66
|
+
return {
|
|
67
|
+
status: 'success',
|
|
68
|
+
link: result.message // Örn: https://secure.paywant.com/common/TOKEN
|
|
69
|
+
};
|
|
70
|
+
} else {
|
|
71
|
+
// Paywant'tan dönen hata
|
|
72
|
+
throw new Error(result.message || 'Paywant ödeme oluşturulamadı.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error.response) {
|
|
77
|
+
throw new Error(`Paywant API Hatası: ${JSON.stringify(error.response.data)}`);
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* IPN (Bildirim) Doğrulama
|
|
85
|
+
* Paywant'tan gelen POST isteğini doğrular.
|
|
86
|
+
* @param {Object} reqBody - Express request body
|
|
87
|
+
*/
|
|
88
|
+
handleCallback(reqBody) {
|
|
89
|
+
const {
|
|
90
|
+
transactionID,
|
|
91
|
+
extraData,
|
|
92
|
+
userID,
|
|
93
|
+
userAccountName,
|
|
94
|
+
status,
|
|
95
|
+
paymentChannel,
|
|
96
|
+
paymentTotal,
|
|
97
|
+
netProfit,
|
|
98
|
+
hash
|
|
99
|
+
} = reqBody;
|
|
100
|
+
|
|
101
|
+
if (!hash) throw new Error('Hash parametresi eksik.');
|
|
102
|
+
|
|
103
|
+
// Dokümantasyondaki IPN Hash Formülü:
|
|
104
|
+
// transactionID|extraData|userID|userAccountName|status|paymentChannel|paymentTotal|netProfit + ApiKey
|
|
105
|
+
const hashString = `${transactionID}|${extraData}|${userID}|${userAccountName}|${status}|${paymentChannel}|${paymentTotal}|${netProfit}${this.apiKey}`;
|
|
106
|
+
|
|
107
|
+
const generatedHash = this.generateHash(hashString);
|
|
108
|
+
|
|
109
|
+
if (hash === generatedHash) {
|
|
110
|
+
// Status 100 = Başarılı
|
|
111
|
+
if (parseInt(status) === 100) {
|
|
112
|
+
return {
|
|
113
|
+
status: 'success',
|
|
114
|
+
orderId: extraData, // Genelde sipariş no extraData'ya konur
|
|
115
|
+
transactionId: transactionID,
|
|
116
|
+
amount: parseFloat(paymentTotal),
|
|
117
|
+
netAmount: parseFloat(netProfit)
|
|
118
|
+
};
|
|
119
|
+
} else {
|
|
120
|
+
return { success: false, message: 'Ödeme başarısız veya beklemede.' };
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error('Hash uyuşmazlığı (Bad Hash)');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* HMAC-SHA256 Hash Üretici (Base64)
|
|
129
|
+
*/
|
|
130
|
+
generateHash(data) {
|
|
131
|
+
return crypto.createHmac('sha256', this.apiSecret)
|
|
132
|
+
.update(data)
|
|
133
|
+
.digest('base64');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = Paywant;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -75,7 +75,11 @@
|
|
|
75
75
|
| **2Checkout** | 🌐 Global | ✅ Active |
|
|
76
76
|
| **İyzico** | 🇹🇷 Turkey | ✅ Active |
|
|
77
77
|
| **PayTR** | 🇹🇷 Turkey | ✅ Active |
|
|
78
|
+
| **PayTR EFT** | 🇹🇷 Turkey | ✅ Active |
|
|
79
|
+
| **PayWant** | 🇹🇷 Turkey | ✅ Active |
|
|
80
|
+
| **PaynKolay** | 🇹🇷 Turkey | ✅ Active |
|
|
78
81
|
| **Shopier** | 🇹🇷 Turkey | ✅ Active |
|
|
82
|
+
| **Shopier Card** | 🇹🇷 Turkey | ✅ Active |
|
|
79
83
|
| **Papara** | 🇹🇷 Turkey | ✅ Active |
|
|
80
84
|
| **EsnekPos** | 🇹🇷 Turkey | ✅ Active |
|
|
81
85
|
| **Paydisini** | 🇹🇷 Turkey | ✅ Active |
|