Skip to content

Commit

Permalink
Address verification for authentication for international cards
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
michael-paystack authored Jul 9, 2020
1 parent ecb830c commit 88b0263
Show file tree
Hide file tree
Showing 15 changed files with 560 additions and 9 deletions.
7 changes: 5 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext.kotlin_version = '1.3.72'
repositories {
jcenter()
google()
Expand All @@ -10,6 +11,7 @@ buildscript {
classpath 'org.robolectric:robolectric-gradle-plugin:1.1.0'
classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5'
classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
Expand All @@ -19,16 +21,17 @@ allprojects {
repositories {
jcenter()
google()
mavenCentral()
}
}

ext {
compileSdkVersion = 29
minSdkVersion = 16
targetSdkVersion = 29
versionCode = 27
versionCode = 18

buildToolsVersion = "29.0.2"
supportLibraryVersion = "28.0.0"
versionName = "3.0.19"
versionName = "3.1.1"
}
4 changes: 4 additions & 0 deletions paystack/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: "com.jfrog.bintray"
apply plugin: 'com.github.dcendents.android-maven'
apply plugin: 'maven-publish'
Expand Down Expand Up @@ -30,6 +31,9 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:3.14.9'
implementation "com.android.support:appcompat-v7:$rootProject.ext.supportLibraryVersion"
implementation 'co.paystack.android.design.widget:pinpad:1.0.4'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7"
}

project.afterEvaluate {
Expand Down
11 changes: 7 additions & 4 deletions paystack/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.paystack.android">
package="co.paystack.android">

<application>
<activity
android:name=".ui.PinActivity"
android:theme="@style/Paystack.Dialog.PinEntry"/>
android:theme="@style/Paystack.Dialog.PinEntry" />
<activity
android:name=".ui.OtpActivity"
android:theme="@style/Paystack.Dialog.OtpEntry" />
<activity
android:name=".ui.AuthActivity"
android:theme="@style/Paystack.Dialog.OtpEntry"/>
android:theme="@style/Paystack.Dialog.OtpEntry" />
<activity
android:name=".ui.CardActivity"
android:theme="@style/Paystack.Dialog.CardEntry"/>
android:theme="@style/Paystack.Dialog.CardEntry" />
<activity
android:name=".ui.AddressVerificationActivity"
android:theme="@style/Paystack.Dialog" />
</application>

</manifest>
63 changes: 61 additions & 2 deletions paystack/src/main/java/co/paystack/android/TransactionManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import co.paystack.android.exceptions.ProcessingException;
import co.paystack.android.model.Card;
import co.paystack.android.model.Charge;
import co.paystack.android.ui.AddressHolder;
import co.paystack.android.ui.AddressHolder.Address;
import co.paystack.android.ui.AddressVerificationActivity;
import co.paystack.android.ui.AuthActivity;
import co.paystack.android.ui.AuthSingleton;
import co.paystack.android.ui.CardActivity;
Expand All @@ -46,6 +49,7 @@ class TransactionManager {
private final PinSingleton psi = PinSingleton.getInstance();
private final OtpSingleton osi = OtpSingleton.getInstance();
private final AuthSingleton asi = AuthSingleton.getInstance();
private final AddressHolder addressHolder = AddressHolder.getInstance();
private ChargeRequestBody chargeRequestBody;
private ValidateRequestBody validateRequestBody;
private ApiService apiService;
Expand Down Expand Up @@ -97,7 +101,7 @@ void chargeCard() {
try {
if (charge.getCard() == null || !charge.getCard().isValid()) {
final CardSingleton si = CardSingleton.getInstance();
synchronized (si){
synchronized (si) {
si.setCard(charge.getCard());
}
new CardAsyncTask().execute();
Expand All @@ -121,7 +125,6 @@ private void sendChargeToServer() {
Log.e(LOG_TAG, ce.getMessage(), ce);
notifyProcessingError(ce);
}

}

private void validate() {
Expand All @@ -144,6 +147,19 @@ private void reQuery() {

}


private void chargeWithAvs(Address address) {
HashMap<String, String> fields = address.toHashMap();
fields.put("trans", transaction.getId());
try {
Call<TransactionApiResponse> call = apiService.submitCardAddress(fields);
call.enqueue(serverCallback);
} catch (Exception e) {
Log.e(LOG_TAG, e.getMessage(), e);
notifyProcessingError(e);
}
}

private void validateChargeOnServer() throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
HashMap<String, String> params = validateRequestBody.getParamsHashMap();
Call<TransactionApiResponse> call = apiService.validateCharge(params);
Expand All @@ -165,6 +181,12 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) {
if (transactionApiResponse == null) {
transactionApiResponse = TransactionApiResponse.unknownServerResponse();
}

// The AVS charge endpoint sends an "errors" object when address verification fails
if (transactionApiResponse.hasErrors) {
notifyProcessingError(new ChargeException(transactionApiResponse.message));
return;
}
transaction.loadFromResponse(transactionApiResponse);

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

if (transactionApiResponse.status.equalsIgnoreCase("2") && transactionApiResponse.auth.equalsIgnoreCase("avs")) {
new AddressVerificationAsyncTask().execute(transactionApiResponse.avsCountryCode);
return;
}

if (transactionApiResponse.status.equalsIgnoreCase("2") || (transactionApiResponse.hasValidAuth() && (transactionApiResponse.auth.equalsIgnoreCase("pin")))) {
new PinAsyncTask().execute();
return;
Expand Down Expand Up @@ -362,4 +389,36 @@ protected void onPostExecute(String responseJson) {
}
}

private class AddressVerificationAsyncTask extends AsyncTask<String, Void, Address> {


@Override
protected Address doInBackground(String... params) {
Intent i = new Intent(activity, AddressVerificationActivity.class);
i.putExtra(AddressVerificationActivity.EXTRA_COUNTRY_CODE, params[0]);
activity.startActivity(i);
synchronized (AddressHolder.getLock()) {
try {
AddressHolder.getLock().wait();
} catch (InterruptedException e) {
notifyProcessingError(new Exception("Address entry Interrupted"));
}
}

return addressHolder.getAddress();
}

@Override
protected void onPostExecute(Address address) {
super.onPostExecute(address);

if (address != null) {
Log.e("AVS_ADDRESS", address.toString());
chargeWithAvs(address);

} else {
notifyProcessingError(new Exception("No address provided"));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public class ApiResponse extends BaseApiModel {
@SerializedName("message")
public String message;

@SerializedName("errors")
public boolean hasErrors = false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public class TransactionApiResponse extends ApiResponse implements Serializable
@SerializedName("otpmessage")
public String otpmessage;

@SerializedName("countryCode")
public String avsCountryCode; // Country code for Address Verification on supported international cards

public static TransactionApiResponse unknownServerResponse() {
TransactionApiResponse t = new TransactionApiResponse();
t.status = "0";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ public interface ApiService {
@GET("/requery/{trans}")
Call<TransactionApiResponse> requeryTransaction(@Path("trans") String trans);

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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package co.paystack.android.mobilemoney.data.api

import android.os.Build
import co.paystack.android.BuildConfig
import co.paystack.android.api.service.PaystackApiService
import co.paystack.android.api.service.converter.WrappedResponseConverter
import co.paystack.android.api.utils.TLSSocketFactory
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.security.KeyManagementException
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
import java.util.concurrent.TimeUnit

/*
* Generates an API client for new paystack API (https://api.paystack.co)
* */
internal object PaystackApiFactory {
private const val BASE_URL = "https://api.paystack.co/"

@Throws(NoSuchAlgorithmException::class, KeyManagementException::class, KeyStoreException::class)
fun createRetrofitService(): PaystackApiService {
val gson = GsonBuilder()
.setDateFormat("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'SSS'Z'")
.create()

val tlsV1point2factory = TLSSocketFactory()
val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val original = chain.request()
// Add headers so we get Android version and Paystack Library version
val builder = original.newBuilder()
.header("User-Agent", "Android_" + Build.VERSION.SDK_INT + "_Paystack_" + BuildConfig.VERSION_NAME)
.header("X-Paystack-Build", BuildConfig.VERSION_CODE.toString())
.header("Accept", "application/json")
.method(original.method(), original.body())
val request = builder.build()
chain.proceed(request)
}
.sslSocketFactory(tlsV1point2factory, tlsV1point2factory.x509TrustManager)
.connectTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.MINUTES)
.writeTimeout(1, TimeUnit.MINUTES)
.build()

val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(WrappedResponseConverter.Factory())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()

return retrofit.create(PaystackApiService::class.java)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package co.paystack.android.api.service

import co.paystack.android.model.AvsState
import retrofit2.http.GET
import retrofit2.http.Query

internal interface PaystackApiService {
@GET("/address_verification/states")
suspend fun getAddressVerificationStates(@Query("country") countryCode: String): List<AvsState>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package co.paystack.android.api.service.converter

import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

class WrappedResponseConverter<T>(
private val delegate: Converter<ResponseBody, WrappedResponse<T>>
) : Converter<ResponseBody, T> {
override fun convert(value: ResponseBody): T? {
val response = delegate.convert(value)
return response?.data
}


class Factory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
val wrappedType: Type = object : ParameterizedType {
override fun getRawType(): Type {
return WrappedResponse::class.java
}

override fun getOwnerType(): Type? {
return null
}

override fun getActualTypeArguments(): Array<Type> {
return arrayOf(type)
}
}

val delegate = retrofit.nextResponseBodyConverter<WrappedResponse<Any>>(this, wrappedType, annotations)
return WrappedResponseConverter(delegate)

}
}

open class WrappedResponse<T>(
val `data`: T,
val message: String,
val status: Boolean
)
}
7 changes: 7 additions & 0 deletions paystack/src/main/java/co/paystack/android/model/AvsState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package co.paystack.android.model

data class AvsState(
val name: String,
val slug: String,
val abbreviation: String
)
Loading

0 comments on commit 88b0263

Please sign in to comment.