Skip to content

Commit 88b0263

Browse files
Address verification for authentication for international cards
* WIP: Address Verification card authentication - Launch verification form when authentication type required is `avs` Signed-off-by: Michael obi <[email protected]> * WIP: AVS - Set test USD card Signed-off-by: Michael obi <[email protected]> * WIP: Address verification system - Submit address to complete charge authentication Signed-off-by: Michael obi <[email protected]> * Complete charge with AVS Signed-off-by: Michael obi <[email protected]> * Remove test helper code some sample app Signed-off-by: Michael obi <[email protected]> * Move mavenCentral repository declaration to project level Signed-off-by: Michael obi <[email protected]> * Bump version code to 18 Signed-off-by: Michael obi <[email protected]> * Revert BASE_URL to https://standard.paystack.co/ Signed-off-by: Michael obi <[email protected]> * Fix issue where PIN responses caused a crash due to absence of `auth` property Signed-off-by: Michael obi <[email protected]> * Handle AVS errors Signed-off-by: Michael obi <[email protected]>
1 parent ecb830c commit 88b0263

15 files changed

+560
-9
lines changed

build.gradle

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Top-level build file where you can add configuration options common to all sub-projects/modules.
22

33
buildscript {
4+
ext.kotlin_version = '1.3.72'
45
repositories {
56
jcenter()
67
google()
@@ -10,6 +11,7 @@ buildscript {
1011
classpath 'org.robolectric:robolectric-gradle-plugin:1.1.0'
1112
classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5'
1213
classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5'
14+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
1315
// NOTE: Do not place your application dependencies here; they belong
1416
// in the individual module build.gradle files
1517
}
@@ -19,16 +21,17 @@ allprojects {
1921
repositories {
2022
jcenter()
2123
google()
24+
mavenCentral()
2225
}
2326
}
2427

2528
ext {
2629
compileSdkVersion = 29
2730
minSdkVersion = 16
2831
targetSdkVersion = 29
29-
versionCode = 27
32+
versionCode = 18
3033

3134
buildToolsVersion = "29.0.2"
3235
supportLibraryVersion = "28.0.0"
33-
versionName = "3.0.19"
36+
versionName = "3.1.1"
3437
}

paystack/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
apply plugin: 'com.android.library'
2+
apply plugin: 'kotlin-android'
23
apply plugin: "com.jfrog.bintray"
34
apply plugin: 'com.github.dcendents.android-maven'
45
apply plugin: 'maven-publish'
@@ -30,6 +31,9 @@ dependencies {
3031
implementation 'com.squareup.okhttp3:okhttp:3.14.9'
3132
implementation "com.android.support:appcompat-v7:$rootProject.ext.supportLibraryVersion"
3233
implementation 'co.paystack.android.design.widget:pinpad:1.0.4'
34+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
35+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
36+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7"
3337
}
3438

3539
project.afterEvaluate {
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3-
package="co.paystack.android">
3+
package="co.paystack.android">
44

55
<application>
66
<activity
77
android:name=".ui.PinActivity"
8-
android:theme="@style/Paystack.Dialog.PinEntry"/>
8+
android:theme="@style/Paystack.Dialog.PinEntry" />
99
<activity
1010
android:name=".ui.OtpActivity"
1111
android:theme="@style/Paystack.Dialog.OtpEntry" />
1212
<activity
1313
android:name=".ui.AuthActivity"
14-
android:theme="@style/Paystack.Dialog.OtpEntry"/>
14+
android:theme="@style/Paystack.Dialog.OtpEntry" />
1515
<activity
1616
android:name=".ui.CardActivity"
17-
android:theme="@style/Paystack.Dialog.CardEntry"/>
17+
android:theme="@style/Paystack.Dialog.CardEntry" />
18+
<activity
19+
android:name=".ui.AddressVerificationActivity"
20+
android:theme="@style/Paystack.Dialog" />
1821
</application>
1922

2023
</manifest>

paystack/src/main/java/co/paystack/android/TransactionManager.java

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
import co.paystack.android.exceptions.ProcessingException;
2323
import co.paystack.android.model.Card;
2424
import co.paystack.android.model.Charge;
25+
import co.paystack.android.ui.AddressHolder;
26+
import co.paystack.android.ui.AddressHolder.Address;
27+
import co.paystack.android.ui.AddressVerificationActivity;
2528
import co.paystack.android.ui.AuthActivity;
2629
import co.paystack.android.ui.AuthSingleton;
2730
import co.paystack.android.ui.CardActivity;
@@ -46,6 +49,7 @@ class TransactionManager {
4649
private final PinSingleton psi = PinSingleton.getInstance();
4750
private final OtpSingleton osi = OtpSingleton.getInstance();
4851
private final AuthSingleton asi = AuthSingleton.getInstance();
52+
private final AddressHolder addressHolder = AddressHolder.getInstance();
4953
private ChargeRequestBody chargeRequestBody;
5054
private ValidateRequestBody validateRequestBody;
5155
private ApiService apiService;
@@ -97,7 +101,7 @@ void chargeCard() {
97101
try {
98102
if (charge.getCard() == null || !charge.getCard().isValid()) {
99103
final CardSingleton si = CardSingleton.getInstance();
100-
synchronized (si){
104+
synchronized (si) {
101105
si.setCard(charge.getCard());
102106
}
103107
new CardAsyncTask().execute();
@@ -121,7 +125,6 @@ private void sendChargeToServer() {
121125
Log.e(LOG_TAG, ce.getMessage(), ce);
122126
notifyProcessingError(ce);
123127
}
124-
125128
}
126129

127130
private void validate() {
@@ -144,6 +147,19 @@ private void reQuery() {
144147

145148
}
146149

150+
151+
private void chargeWithAvs(Address address) {
152+
HashMap<String, String> fields = address.toHashMap();
153+
fields.put("trans", transaction.getId());
154+
try {
155+
Call<TransactionApiResponse> call = apiService.submitCardAddress(fields);
156+
call.enqueue(serverCallback);
157+
} catch (Exception e) {
158+
Log.e(LOG_TAG, e.getMessage(), e);
159+
notifyProcessingError(e);
160+
}
161+
}
162+
147163
private void validateChargeOnServer() throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
148164
HashMap<String, String> params = validateRequestBody.getParamsHashMap();
149165
Call<TransactionApiResponse> call = apiService.validateCharge(params);
@@ -165,6 +181,12 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) {
165181
if (transactionApiResponse == null) {
166182
transactionApiResponse = TransactionApiResponse.unknownServerResponse();
167183
}
184+
185+
// The AVS charge endpoint sends an "errors" object when address verification fails
186+
if (transactionApiResponse.hasErrors) {
187+
notifyProcessingError(new ChargeException(transactionApiResponse.message));
188+
return;
189+
}
168190
transaction.loadFromResponse(transactionApiResponse);
169191

170192
if (transactionApiResponse.status.equalsIgnoreCase("1") || transactionApiResponse.status.equalsIgnoreCase("success")) {
@@ -173,6 +195,11 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) {
173195
return;
174196
}
175197

198+
if (transactionApiResponse.status.equalsIgnoreCase("2") && transactionApiResponse.auth.equalsIgnoreCase("avs")) {
199+
new AddressVerificationAsyncTask().execute(transactionApiResponse.avsCountryCode);
200+
return;
201+
}
202+
176203
if (transactionApiResponse.status.equalsIgnoreCase("2") || (transactionApiResponse.hasValidAuth() && (transactionApiResponse.auth.equalsIgnoreCase("pin")))) {
177204
new PinAsyncTask().execute();
178205
return;
@@ -362,4 +389,36 @@ protected void onPostExecute(String responseJson) {
362389
}
363390
}
364391

392+
private class AddressVerificationAsyncTask extends AsyncTask<String, Void, Address> {
393+
394+
395+
@Override
396+
protected Address doInBackground(String... params) {
397+
Intent i = new Intent(activity, AddressVerificationActivity.class);
398+
i.putExtra(AddressVerificationActivity.EXTRA_COUNTRY_CODE, params[0]);
399+
activity.startActivity(i);
400+
synchronized (AddressHolder.getLock()) {
401+
try {
402+
AddressHolder.getLock().wait();
403+
} catch (InterruptedException e) {
404+
notifyProcessingError(new Exception("Address entry Interrupted"));
405+
}
406+
}
407+
408+
return addressHolder.getAddress();
409+
}
410+
411+
@Override
412+
protected void onPostExecute(Address address) {
413+
super.onPostExecute(address);
414+
415+
if (address != null) {
416+
Log.e("AVS_ADDRESS", address.toString());
417+
chargeWithAvs(address);
418+
419+
} else {
420+
notifyProcessingError(new Exception("No address provided"));
421+
}
422+
}
423+
}
365424
}

paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ public class ApiResponse extends BaseApiModel {
1313
@SerializedName("message")
1414
public String message;
1515

16+
@SerializedName("errors")
17+
public boolean hasErrors = false;
1618
}

paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public class TransactionApiResponse extends ApiResponse implements Serializable
2424
@SerializedName("otpmessage")
2525
public String otpmessage;
2626

27+
@SerializedName("countryCode")
28+
public String avsCountryCode; // Country code for Address Verification on supported international cards
29+
2730
public static TransactionApiResponse unknownServerResponse() {
2831
TransactionApiResponse t = new TransactionApiResponse();
2932
t.status = "0";

paystack/src/main/java/co/paystack/android/api/service/ApiService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ public interface ApiService {
2626
@GET("/requery/{trans}")
2727
Call<TransactionApiResponse> requeryTransaction(@Path("trans") String trans);
2828

29+
@FormUrlEncoded
30+
@POST("/charge/avs")
31+
Call<TransactionApiResponse> submitCardAddress(@FieldMap HashMap<String, String> fields);
2932

3033
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package co.paystack.android.mobilemoney.data.api
2+
3+
import android.os.Build
4+
import co.paystack.android.BuildConfig
5+
import co.paystack.android.api.service.PaystackApiService
6+
import co.paystack.android.api.service.converter.WrappedResponseConverter
7+
import co.paystack.android.api.utils.TLSSocketFactory
8+
import com.google.gson.GsonBuilder
9+
import okhttp3.OkHttpClient
10+
import retrofit2.Retrofit
11+
import retrofit2.converter.gson.GsonConverterFactory
12+
import java.security.KeyManagementException
13+
import java.security.KeyStoreException
14+
import java.security.NoSuchAlgorithmException
15+
import java.util.concurrent.TimeUnit
16+
17+
/*
18+
* Generates an API client for new paystack API (https://api.paystack.co)
19+
* */
20+
internal object PaystackApiFactory {
21+
private const val BASE_URL = "https://api.paystack.co/"
22+
23+
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class, KeyStoreException::class)
24+
fun createRetrofitService(): PaystackApiService {
25+
val gson = GsonBuilder()
26+
.setDateFormat("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'SSS'Z'")
27+
.create()
28+
29+
val tlsV1point2factory = TLSSocketFactory()
30+
val okHttpClient = OkHttpClient.Builder()
31+
.addInterceptor { chain ->
32+
val original = chain.request()
33+
// Add headers so we get Android version and Paystack Library version
34+
val builder = original.newBuilder()
35+
.header("User-Agent", "Android_" + Build.VERSION.SDK_INT + "_Paystack_" + BuildConfig.VERSION_NAME)
36+
.header("X-Paystack-Build", BuildConfig.VERSION_CODE.toString())
37+
.header("Accept", "application/json")
38+
.method(original.method(), original.body())
39+
val request = builder.build()
40+
chain.proceed(request)
41+
}
42+
.sslSocketFactory(tlsV1point2factory, tlsV1point2factory.x509TrustManager)
43+
.connectTimeout(1, TimeUnit.MINUTES)
44+
.readTimeout(1, TimeUnit.MINUTES)
45+
.writeTimeout(1, TimeUnit.MINUTES)
46+
.build()
47+
48+
val retrofit = Retrofit.Builder()
49+
.baseUrl(BASE_URL)
50+
.client(okHttpClient)
51+
.addConverterFactory(WrappedResponseConverter.Factory())
52+
.addConverterFactory(GsonConverterFactory.create(gson))
53+
.build()
54+
55+
return retrofit.create(PaystackApiService::class.java)
56+
}
57+
58+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package co.paystack.android.api.service
2+
3+
import co.paystack.android.model.AvsState
4+
import retrofit2.http.GET
5+
import retrofit2.http.Query
6+
7+
internal interface PaystackApiService {
8+
@GET("/address_verification/states")
9+
suspend fun getAddressVerificationStates(@Query("country") countryCode: String): List<AvsState>
10+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package co.paystack.android.api.service.converter
2+
3+
import okhttp3.ResponseBody
4+
import retrofit2.Converter
5+
import retrofit2.Retrofit
6+
import java.lang.reflect.ParameterizedType
7+
import java.lang.reflect.Type
8+
9+
class WrappedResponseConverter<T>(
10+
private val delegate: Converter<ResponseBody, WrappedResponse<T>>
11+
) : Converter<ResponseBody, T> {
12+
override fun convert(value: ResponseBody): T? {
13+
val response = delegate.convert(value)
14+
return response?.data
15+
}
16+
17+
18+
class Factory : Converter.Factory() {
19+
override fun responseBodyConverter(
20+
type: Type,
21+
annotations: Array<Annotation>,
22+
retrofit: Retrofit
23+
): Converter<ResponseBody, *>? {
24+
val wrappedType: Type = object : ParameterizedType {
25+
override fun getRawType(): Type {
26+
return WrappedResponse::class.java
27+
}
28+
29+
override fun getOwnerType(): Type? {
30+
return null
31+
}
32+
33+
override fun getActualTypeArguments(): Array<Type> {
34+
return arrayOf(type)
35+
}
36+
}
37+
38+
val delegate = retrofit.nextResponseBodyConverter<WrappedResponse<Any>>(this, wrappedType, annotations)
39+
return WrappedResponseConverter(delegate)
40+
41+
}
42+
}
43+
44+
open class WrappedResponse<T>(
45+
val `data`: T,
46+
val message: String,
47+
val status: Boolean
48+
)
49+
}

0 commit comments

Comments
 (0)