@bloonio/lokotro-pay 1.2.2 → 1.3.0
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.
|
@@ -17,8 +17,8 @@ import { HttpHeaders, HttpErrorResponse } from '@angular/common/http';
|
|
|
17
17
|
*/
|
|
18
18
|
const LOKOTRO_DEV_ENV = {
|
|
19
19
|
environment: 'development',
|
|
20
|
-
// apiBaseUrl: 'http://
|
|
21
|
-
apiBaseUrl: 'https://
|
|
20
|
+
// apiBaseUrl: 'http://192.168.30.118:6495',
|
|
21
|
+
apiBaseUrl: 'https://app.api.gtwy.lokotro.com',
|
|
22
22
|
paymentApiVersion: 'v1',
|
|
23
23
|
debugMode: true,
|
|
24
24
|
logLevel: 'debug',
|
|
@@ -1244,16 +1244,19 @@ class LokotroCountryUtils {
|
|
|
1244
1244
|
* Angular version of the Flutter Lokotro Pay HTTP client
|
|
1245
1245
|
*/
|
|
1246
1246
|
/**
|
|
1247
|
-
* Enhanced HTTP client for Lokotro Pay with modern error handling and logging
|
|
1247
|
+
* Enhanced HTTP client for Lokotro Pay with modern error handling and logging.
|
|
1248
|
+
*
|
|
1249
|
+
* PR-1.2 / CRIT-2: previous code emitted six `console.log` calls that
|
|
1250
|
+
* truncated and printed the merchant `appKey` to the browser console on every
|
|
1251
|
+
* HTTP request. The truncated form ("first 20 chars + ...") is still useful
|
|
1252
|
+
* to anyone scraping the console, especially when paired with the response
|
|
1253
|
+
* body. All such logs were removed.
|
|
1248
1254
|
*/
|
|
1249
1255
|
class LokotroHttpClientService {
|
|
1250
|
-
static { this.instanceCounter = 0; }
|
|
1251
1256
|
constructor(http) {
|
|
1252
1257
|
this.http = http;
|
|
1253
1258
|
this.acceptLanguage = 'fr';
|
|
1254
1259
|
this.customHeaders = {};
|
|
1255
|
-
this.instanceId = ++LokotroHttpClientService.instanceCounter;
|
|
1256
|
-
console.log(`[Lokotro HTTP] Instance #${this.instanceId} created`);
|
|
1257
1260
|
}
|
|
1258
1261
|
/**
|
|
1259
1262
|
* Configure the HTTP client
|
|
@@ -1273,9 +1276,7 @@ class LokotroHttpClientService {
|
|
|
1273
1276
|
* Set app-key for Lokotro Gateway authentication
|
|
1274
1277
|
*/
|
|
1275
1278
|
setAppKey(appKey) {
|
|
1276
|
-
console.log(`[Lokotro HTTP #${this.instanceId}] setAppKey called with:`, appKey?.substring(0, 20) + '...');
|
|
1277
1279
|
this.appKey = appKey;
|
|
1278
|
-
console.log(`[Lokotro HTTP #${this.instanceId}] appKey now set to:`, this.appKey?.substring(0, 20) + '...');
|
|
1279
1280
|
}
|
|
1280
1281
|
/**
|
|
1281
1282
|
* Remove app-key
|
|
@@ -1299,7 +1300,6 @@ class LokotroHttpClientService {
|
|
|
1299
1300
|
* Build request headers
|
|
1300
1301
|
*/
|
|
1301
1302
|
buildHeaders() {
|
|
1302
|
-
console.log(`[Lokotro HTTP #${this.instanceId}] buildHeaders - appKey value:`, this.appKey?.substring(0, 20) + '...');
|
|
1303
1303
|
let headers = new HttpHeaders({
|
|
1304
1304
|
'Content-Type': 'application/json',
|
|
1305
1305
|
'Accept': 'application/json',
|
|
@@ -1308,11 +1308,14 @@ class LokotroHttpClientService {
|
|
|
1308
1308
|
'Accept-Language': this.acceptLanguage
|
|
1309
1309
|
});
|
|
1310
1310
|
if (this.appKey) {
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1311
|
+
// Auth-v3 / PR-3.5: the gateway's /api/v1/payments/* endpoints now
|
|
1312
|
+
// authenticate via `Authorization: Bearer <session_token>`, where
|
|
1313
|
+
// `session_token` is minted server-to-server by the merchant backend
|
|
1314
|
+
// calling /api/v1/internal/sessions/create. `this.appKey` here holds
|
|
1315
|
+
// that session_token (the field name is kept for backwards-compat
|
|
1316
|
+
// with merchants who haven't migrated yet — gateway returns 401 for
|
|
1317
|
+
// legacy app-key on /collect anyway).
|
|
1318
|
+
headers = headers.set('Authorization', `Bearer ${this.appKey}`);
|
|
1316
1319
|
}
|
|
1317
1320
|
// Add custom headers
|
|
1318
1321
|
Object.entries(this.customHeaders).forEach(([key, value]) => {
|
|
@@ -1388,11 +1391,20 @@ class LokotroHttpClientService {
|
|
|
1388
1391
|
};
|
|
1389
1392
|
}
|
|
1390
1393
|
/**
|
|
1391
|
-
* Handle error response
|
|
1394
|
+
* Handle error response.
|
|
1395
|
+
*
|
|
1396
|
+
* PR-1.2: previously logged the full `error` object in debug mode, which
|
|
1397
|
+
* for an HttpErrorResponse includes `error.error` — i.e. the raw server
|
|
1398
|
+
* response body. That body can leak validation echoes of the original
|
|
1399
|
+
* request (PAN/PIN/etc.) when the backend is in development mode. Now we
|
|
1400
|
+
* log only HTTP status code + url; never the body.
|
|
1392
1401
|
*/
|
|
1393
1402
|
handleError(error) {
|
|
1394
1403
|
if (LokotroPayEnv.debugMode) {
|
|
1395
|
-
|
|
1404
|
+
const status = error instanceof HttpErrorResponse ? error.status : 0;
|
|
1405
|
+
const url = error instanceof HttpErrorResponse ? error.url : '';
|
|
1406
|
+
// eslint-disable-next-line no-console
|
|
1407
|
+
console.error('[Lokotro Pay HTTP] Error', { status, url });
|
|
1396
1408
|
}
|
|
1397
1409
|
let errorResponse;
|
|
1398
1410
|
if (error instanceof HttpErrorResponse) {
|
|
@@ -3392,9 +3404,12 @@ class LokotroPaymentService {
|
|
|
3392
3404
|
this.currentPaymentBody = paymentBody;
|
|
3393
3405
|
this.updateState({ isLoading: true });
|
|
3394
3406
|
const requestData = this.convertPaymentBodyToRequest(paymentBody);
|
|
3395
|
-
console.log(
|
|
3407
|
+
// PR-1.2 / CRIT-1: previous `console.log(requestData)` shipped PAN, CVV,
|
|
3408
|
+
// wallet PIN, flash PIN, and full PII to the browser console on every
|
|
3409
|
+
// checkout. Removed.
|
|
3396
3410
|
return this.httpClient.post(LokotroPayEnv.endpoints.collect, requestData).pipe(switchMap(response => {
|
|
3397
|
-
|
|
3411
|
+
// PR-1.2: response contains transaction IDs and (sometimes) redirect
|
|
3412
|
+
// URLs that are sensitive. Don't log it.
|
|
3398
3413
|
if (!response.isSuccess || !response.data) {
|
|
3399
3414
|
this.updateState({
|
|
3400
3415
|
isLoading: false,
|
|
@@ -3456,7 +3471,10 @@ class LokotroPaymentService {
|
|
|
3456
3471
|
isLoading: false,
|
|
3457
3472
|
transactionId: submitResponse.transactionId || this.resolvePaymentId(request)
|
|
3458
3473
|
});
|
|
3459
|
-
|
|
3474
|
+
// PR-1.2 / Angular HIGH-1: validate redirect URL against allow-list
|
|
3475
|
+
// before handing the tab over. Server-side allow-list (PR-2.0) is
|
|
3476
|
+
// the real defense; this is defense-in-depth on the client.
|
|
3477
|
+
this.redirectIfAllowed(submitResponse.redirectUrl, 'submit-response');
|
|
3460
3478
|
return;
|
|
3461
3479
|
}
|
|
3462
3480
|
if (submitResponse.status === LokotroPaymentStatus.PendingBankProofUpload) {
|
|
@@ -3562,11 +3580,11 @@ class LokotroPaymentService {
|
|
|
3562
3580
|
startMobileMoneyPolling(transactionId) {
|
|
3563
3581
|
this.stopMobileMoneyPolling();
|
|
3564
3582
|
this.mobileMoneyPollingAttempts = 0;
|
|
3565
|
-
console.log
|
|
3583
|
+
// PR-1.2: removed console.log of transactionId — operational logging
|
|
3584
|
+
// should be opt-in via merchant config, not on by default.
|
|
3566
3585
|
this.mobileMoneyPollingTimer = setInterval(() => {
|
|
3567
3586
|
this.mobileMoneyPollingAttempts++;
|
|
3568
3587
|
if (this.mobileMoneyPollingAttempts > LokotroPaymentService.MOBILE_MONEY_MAX_ATTEMPTS) {
|
|
3569
|
-
console.warn('[Lokotro Payment] Mobile money polling max attempts reached');
|
|
3570
3588
|
this.stopMobileMoneyPolling();
|
|
3571
3589
|
this.handlePaymentFailure('Payment took too long. Please try again or contact support.');
|
|
3572
3590
|
return;
|
|
@@ -3578,7 +3596,6 @@ class LokotroPaymentService {
|
|
|
3578
3596
|
return;
|
|
3579
3597
|
const data = this.unwrapPayload(response.data);
|
|
3580
3598
|
const status = this.getString(data['status']).toLowerCase();
|
|
3581
|
-
console.log(`[Lokotro Payment] Mobile money poll #${this.mobileMoneyPollingAttempts}: status=${status}`);
|
|
3582
3599
|
if (status === 'completed' || status === 'success' || status === 'approved') {
|
|
3583
3600
|
this.stopMobileMoneyPolling();
|
|
3584
3601
|
this.handlePaymentSuccess(data);
|
|
@@ -3588,8 +3605,9 @@ class LokotroPaymentService {
|
|
|
3588
3605
|
this.handlePaymentFailure(this.getString(data['message']) || 'Payment failed or was declined');
|
|
3589
3606
|
}
|
|
3590
3607
|
},
|
|
3591
|
-
error: (
|
|
3592
|
-
|
|
3608
|
+
error: (_err) => {
|
|
3609
|
+
// PR-1.2: don't log full error object — may contain backend
|
|
3610
|
+
// response details that leak server internals to console.
|
|
3593
3611
|
}
|
|
3594
3612
|
});
|
|
3595
3613
|
}, LokotroPaymentService.MOBILE_MONEY_POLL_INTERVAL);
|
|
@@ -3602,9 +3620,47 @@ class LokotroPaymentService {
|
|
|
3602
3620
|
clearInterval(this.mobileMoneyPollingTimer);
|
|
3603
3621
|
this.mobileMoneyPollingTimer = undefined;
|
|
3604
3622
|
this.mobileMoneyPollingAttempts = 0;
|
|
3605
|
-
console.log('[Lokotro Payment] Mobile money polling stopped');
|
|
3606
3623
|
}
|
|
3607
3624
|
}
|
|
3625
|
+
/**
|
|
3626
|
+
* Validate a server-supplied redirect URL before handing the browser tab to
|
|
3627
|
+
* it. Defense-in-depth for Angular HIGH-1 — server-side per-merchant
|
|
3628
|
+
* allow-list (PR-2.0) is the real fix.
|
|
3629
|
+
*
|
|
3630
|
+
* Policy:
|
|
3631
|
+
* - Reject non-http(s) schemes (blocks `javascript:`, `data:`, `file:`,
|
|
3632
|
+
* `intent:`, custom schemes).
|
|
3633
|
+
* - Reject http:// in production.
|
|
3634
|
+
* - Reject malformed URLs.
|
|
3635
|
+
* - SSR safety: skip the redirect when window is unavailable.
|
|
3636
|
+
*/
|
|
3637
|
+
redirectIfAllowed(url, _origin) {
|
|
3638
|
+
if (typeof window === 'undefined') {
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
if (!url || typeof url !== 'string') {
|
|
3642
|
+
this.handlePaymentFailure('Invalid redirect URL');
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
let parsed;
|
|
3646
|
+
try {
|
|
3647
|
+
parsed = new URL(url, window.location.href);
|
|
3648
|
+
}
|
|
3649
|
+
catch {
|
|
3650
|
+
this.handlePaymentFailure('Untrusted redirect URL');
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
3654
|
+
this.handlePaymentFailure('Untrusted redirect scheme');
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
const isProduction = LokotroPayEnv.environment === 'production';
|
|
3658
|
+
if (isProduction && parsed.protocol !== 'https:') {
|
|
3659
|
+
this.handlePaymentFailure('Insecure redirect rejected');
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
window.location.href = parsed.toString();
|
|
3663
|
+
}
|
|
3608
3664
|
/**
|
|
3609
3665
|
* Handle payment success
|
|
3610
3666
|
*/
|
|
@@ -3682,7 +3738,8 @@ class LokotroPaymentService {
|
|
|
3682
3738
|
isLoading: false,
|
|
3683
3739
|
transactionId: paymentId
|
|
3684
3740
|
});
|
|
3685
|
-
|
|
3741
|
+
// PR-1.2 / Angular HIGH-1: validate before redirect.
|
|
3742
|
+
this.redirectIfAllowed(redirectUrl, 'session-created');
|
|
3686
3743
|
return of(void 0);
|
|
3687
3744
|
}
|
|
3688
3745
|
if (this.getString(additionalData?.['action']) === 'select_payment_method') {
|
|
@@ -3731,7 +3788,8 @@ class LokotroPaymentService {
|
|
|
3731
3788
|
this.getString(transactionData['redirect_url']);
|
|
3732
3789
|
if (hostedCheckoutUrl) {
|
|
3733
3790
|
this.updateState({ isLoading: false });
|
|
3734
|
-
|
|
3791
|
+
// PR-1.2 / Angular HIGH-1: validate hosted-checkout URL.
|
|
3792
|
+
this.redirectIfAllowed(hostedCheckoutUrl, 'hosted-checkout');
|
|
3735
3793
|
return of(void 0);
|
|
3736
3794
|
}
|
|
3737
3795
|
}
|
|
@@ -3778,15 +3836,25 @@ class LokotroPaymentService {
|
|
|
3778
3836
|
*/
|
|
3779
3837
|
convertPaymentBodyToRequest(body) {
|
|
3780
3838
|
const request = {
|
|
3781
|
-
customer_reference: body.customerReference,
|
|
3782
|
-
amount: body.amount,
|
|
3783
|
-
currency: body.currency.toLowerCase(),
|
|
3784
3839
|
payment_method: body.paymentMethod || 'wallet',
|
|
3785
3840
|
user_info: body.userInfo || 'full',
|
|
3786
3841
|
payment_method_info: body.paymentMethodInfo || 'full',
|
|
3787
3842
|
fee_covered_by: body.feeCoveredBy || 'buyer',
|
|
3788
3843
|
delivery_behaviour: body.deliveryBehaviour || 'direct_delivery'
|
|
3789
3844
|
};
|
|
3845
|
+
// Auth-v3 refactor: customer_reference / amount / currency are bound
|
|
3846
|
+
// on the session_token. The gateway reads them straight from the
|
|
3847
|
+
// session — sending them again on the wire is redundant, and was
|
|
3848
|
+
// the source of the `"1.00"` vs `"1"` formatting drift that 400'd
|
|
3849
|
+
// body-binding. Only include them when the merchant explicitly set
|
|
3850
|
+
// them (legacy / display reasons). The gateway treats them as a
|
|
3851
|
+
// soft check when present, ignores when absent.
|
|
3852
|
+
if (body.customerReference)
|
|
3853
|
+
request['customer_reference'] = body.customerReference;
|
|
3854
|
+
if (body.amount)
|
|
3855
|
+
request['amount'] = body.amount;
|
|
3856
|
+
if (body.currency)
|
|
3857
|
+
request['currency'] = body.currency.toLowerCase();
|
|
3790
3858
|
if (body.notifyUrl)
|
|
3791
3859
|
request['notify_url'] = body.notifyUrl;
|
|
3792
3860
|
if (body.successRedirectUrl)
|
|
@@ -3966,6 +4034,9 @@ class LokotroPaymentService {
|
|
|
3966
4034
|
name,
|
|
3967
4035
|
displayName: this.getString(data['display_name']) || name,
|
|
3968
4036
|
channel: this.parseChannel(rawChannel),
|
|
4037
|
+
// PR-3.0 — pull the provider flag through. Legacy responses omit it →
|
|
4038
|
+
// remains undefined, consumers fall back to channel-level branching.
|
|
4039
|
+
flag: this.parsePaymentMethodFlag(this.getString(data['flag'])),
|
|
3969
4040
|
iconUrl: this.getString(data['icon_url']) || this.getString(data['icon']),
|
|
3970
4041
|
isEnabled: this.getBoolean(data['is_enabled'], this.getBoolean(data['available'], true)),
|
|
3971
4042
|
configuration: this.asRecord(data['configuration']),
|
|
@@ -3974,6 +4045,19 @@ class LokotroPaymentService {
|
|
|
3974
4045
|
: undefined
|
|
3975
4046
|
};
|
|
3976
4047
|
}
|
|
4048
|
+
/**
|
|
4049
|
+
* PR-3.0 — narrow an arbitrary string to the LokotroPaymentMethodFlag union.
|
|
4050
|
+
* Unknown / empty inputs return undefined so the field stays optional.
|
|
4051
|
+
*/
|
|
4052
|
+
parsePaymentMethodFlag(value) {
|
|
4053
|
+
const known = [
|
|
4054
|
+
'none', 'all',
|
|
4055
|
+
'onafriq_mobile_money', 'onafriq_credit_card', 'rawbank_credit_card',
|
|
4056
|
+
'lokotro_wallet', 'lokotro_eflash',
|
|
4057
|
+
'cash', 'bank_transfer', 'google_pay', 'apple_pay',
|
|
4058
|
+
];
|
|
4059
|
+
return known.find(f => f === value);
|
|
4060
|
+
}
|
|
3977
4061
|
/**
|
|
3978
4062
|
* Parse submit response from API.
|
|
3979
4063
|
*/
|
|
@@ -4193,18 +4277,13 @@ class LokotroPayCheckoutComponent {
|
|
|
4193
4277
|
}
|
|
4194
4278
|
}
|
|
4195
4279
|
initializeEnvironment() {
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
isProduction: this.configs.isProduction,
|
|
4199
|
-
customApiUrl: this.configs.customApiUrl
|
|
4200
|
-
});
|
|
4280
|
+
// PR-1.2 / CRIT-2: previous logs printed a truncated form of the merchant
|
|
4281
|
+
// token to the browser console on every checkout init. Removed.
|
|
4201
4282
|
LokotroPayEnv.initialize(this.configs.isProduction ?? false, this.configs.customApiUrl);
|
|
4202
|
-
console.log('[Lokotro Checkout] Calling setAppKey with token');
|
|
4203
4283
|
this.paymentService.setAppKey(this.configs.token);
|
|
4204
4284
|
if (this.configs.acceptLanguage) {
|
|
4205
4285
|
this.paymentService.setAcceptLanguage(this.configs.acceptLanguage);
|
|
4206
4286
|
}
|
|
4207
|
-
console.log('[Lokotro Checkout] Environment initialized');
|
|
4208
4287
|
}
|
|
4209
4288
|
initializeLocalization() {
|
|
4210
4289
|
if (this.language) {
|