Skip to content

Commit

Permalink
SEPA Launcher API (#816)
Browse files Browse the repository at this point in the history
* SEPA Launcher API [Source Code] (#812)
* SEPA Launcher API [Tests] (#814)
  • Loading branch information
scannillo authored Nov 6, 2023
1 parent 69eca32 commit 785b207
Show file tree
Hide file tree
Showing 16 changed files with 570 additions and 431 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
* Card
* Remove `threeDSecureInfo` from `CardNonce`
* Move `ThreeDSecureInfo` to `three-d-secure` module
* SEPA Direct Debit
* Remove `SEPADirectDebitLifecycleObserver` and `SEPADirectDebitListener`
* Add `SEPADirectDebitLauncher`, `SEPADirectDebitLauncherCallback`, `SEPADirectDebitFlowStartedCallback`,
`SEPADirectDebitResponse`, `SEPADirectDebitBrowserSwitchResult`, and `SEPADirectDebitBrowserSwitchResultCallback`
* Remove Fragment or Activity requirement from `SEPADirectDebitClient` constructor
* Modify `SEPADirectDebitClient#tokenize` parameters

## unreleased

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@

import com.braintreepayments.api.BraintreeClient;
import com.braintreepayments.api.PostalAddress;
import com.braintreepayments.api.SEPADirectDebitBrowserSwitchResult;
import com.braintreepayments.api.SEPADirectDebitClient;
import com.braintreepayments.api.SEPADirectDebitListener;
import com.braintreepayments.api.SEPADirectDebitLauncher;
import com.braintreepayments.api.SEPADirectDebitLauncherCallback;
import com.braintreepayments.api.SEPADirectDebitMandateType;
import com.braintreepayments.api.SEPADirectDebitNonce;
import com.braintreepayments.api.SEPADirectDebitRequest;

import java.util.UUID;

public class SEPADirectDebitFragment extends BaseFragment implements SEPADirectDebitListener {
public class SEPADirectDebitFragment extends BaseFragment {

private SEPADirectDebitClient sepaDirectDebitClient;

private SEPADirectDebitLauncher sepaDirectDebitLauncher;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
Expand All @@ -31,12 +33,27 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container,
button.setOnClickListener(this::launchSEPADirectDebit);

BraintreeClient braintreeClient = getBraintreeClient();
sepaDirectDebitClient = new SEPADirectDebitClient(this, braintreeClient);
sepaDirectDebitClient.setListener(this);
sepaDirectDebitClient = new SEPADirectDebitClient(braintreeClient);

sepaDirectDebitLauncher = new SEPADirectDebitLauncher(sepaDirectDebitBrowserSwitchResult ->
sepaDirectDebitClient.onBrowserSwitchResult(sepaDirectDebitBrowserSwitchResult, (sepaDirectDebitNonce, error) -> {
if (error != null) {
handleError(error);
} else {
handleSEPANonce(sepaDirectDebitNonce);
}
})
);

return view;
}

@Override
public void onResume() {
super.onResume();
sepaDirectDebitLauncher.handleReturnToAppFromBrowser(requireContext(), requireActivity().getIntent());
}

public void launchSEPADirectDebit(View view) {
PostalAddress billingAddress = new PostalAddress();
billingAddress.setStreetAddress("Kantstraße 70");
Expand All @@ -54,24 +71,26 @@ public void launchSEPADirectDebit(View view) {
request.setBillingAddress(billingAddress);
request.setMerchantAccountId("EUR-sepa-direct-debit");

sepaDirectDebitClient.tokenize(requireActivity(), request);
sepaDirectDebitClient.tokenize(request, (sepaDirectDebitResponse, error) -> {
if (error != null) {
handleError(error);
} else if (sepaDirectDebitResponse.getNonce() != null) { // web-flow mandate not required
handleSEPANonce(sepaDirectDebitResponse.getNonce());
} else { // web-flow mandate required
sepaDirectDebitLauncher.launch(requireActivity(), sepaDirectDebitResponse);
}
});
}

private String generateRandomCustomerId() {
return UUID.randomUUID().toString().substring(0,20);
}

@Override
public void onSEPADirectDebitSuccess(@NonNull SEPADirectDebitNonce sepaDirectDebitNonce) {
private void handleSEPANonce(@NonNull SEPADirectDebitNonce sepaDirectDebitNonce) {
super.onPaymentMethodNonceCreated(sepaDirectDebitNonce);

SEPADirectDebitFragmentDirections.ActionSepaDirectDebitFragmentToDisplayNonceFragment action =
SEPADirectDebitFragmentDirections.actionSepaDirectDebitFragmentToDisplayNonceFragment(sepaDirectDebitNonce);
NavHostFragment.findNavController(this).navigate(action);
}

@Override
public void onSEPADirectDebitFailure(@NonNull Exception error) {
handleError(error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.braintreepayments.api;

/**
* Result received from the SEPA mandate web flow through {@link SEPADirectDebitBrowserSwitchResultCallback}.
* This result should be passed to
* {@link SEPADirectDebitClient#onBrowserSwitchResult(SEPADirectDebitBrowserSwitchResult, SEPADirectDebitBrowserSwitchResultCallback)} )}
* to complete the SEPA mandate flow.
*/
public class SEPADirectDebitBrowserSwitchResult {

private BrowserSwitchResult browserSwitchResult;
private Exception error;

SEPADirectDebitBrowserSwitchResult(BrowserSwitchResult browserSwitchResult) {
this.browserSwitchResult = browserSwitchResult;
}

SEPADirectDebitBrowserSwitchResult(Exception error) {
this.error = error;
}

BrowserSwitchResult getBrowserSwitchResult() {
return browserSwitchResult;
}

Exception getError() {
return error;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.braintreepayments.api;

import androidx.annotation.Nullable;

/**
* Callback for receiving result of
* {@link SEPADirectDebitClient#onBrowserSwitchResult(SEPADirectDebitBrowserSwitchResult, SEPADirectDebitBrowserSwitchResultCallback)}.
*/
public interface SEPADirectDebitBrowserSwitchResultCallback {

/**
* @param sepaDirectDebitNonce {@link SEPADirectDebitNonce}
* @param error an exception that occurred while processing a PayPal result
*/
void onResult(@Nullable SEPADirectDebitNonce sepaDirectDebitNonce, @Nullable Exception error);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.braintreepayments.api;

import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.webkit.URLUtil;

Expand All @@ -25,65 +27,32 @@ public class SEPADirectDebitClient {

private final SEPADirectDebitApi sepaDirectDebitApi;
private final BraintreeClient braintreeClient;
private SEPADirectDebitListener listener;

/**
* Create a new instance of {@link SEPADirectDebitClient} from within an Activity using a
* {@link BraintreeClient}.
* Create a new instance of {@link SEPADirectDebitClient} using a {@link BraintreeClient}.
*
* @param activity an Android FragmentActivity
* @param braintreeClient a {@link BraintreeClient}
*/
public SEPADirectDebitClient(@NonNull FragmentActivity activity,
@NonNull BraintreeClient braintreeClient) {
this(activity, activity.getLifecycle(), braintreeClient,
new SEPADirectDebitApi(braintreeClient));
}

/**
* Create a new instance of {@link SEPADirectDebitClient} from within a Fragment using a
* {@link BraintreeClient}.
*
* @param fragment an Android Fragment
* @param braintreeClient a {@link BraintreeClient}
*/
public SEPADirectDebitClient(@NonNull Fragment fragment,
@NonNull BraintreeClient braintreeClient) {
this(fragment.getActivity(), fragment.getLifecycle(), braintreeClient,
new SEPADirectDebitApi(braintreeClient));
public SEPADirectDebitClient(@NonNull BraintreeClient braintreeClient) {
this(braintreeClient, new SEPADirectDebitApi(braintreeClient));
}

@VisibleForTesting
SEPADirectDebitClient(FragmentActivity activity, Lifecycle lifecycle,
BraintreeClient braintreeClient, SEPADirectDebitApi sepaDirectDebitApi) {
this.sepaDirectDebitApi = sepaDirectDebitApi;
SEPADirectDebitClient(BraintreeClient braintreeClient, SEPADirectDebitApi sepaDirectDebitApi) {
this.braintreeClient = braintreeClient;
if (activity != null && lifecycle != null) {
SEPADirectDebitLifecycleObserver observer = new SEPADirectDebitLifecycleObserver(this);
lifecycle.addObserver(observer);
}
}

/**
* Add a {@link SEPADirectDebitListener} to your client to receive results or errors from the
* SEPA Direct Debit flow.
*
* @param listener a {@link SEPADirectDebitListener}
*/
public void setListener(SEPADirectDebitListener listener) {
this.listener = listener;
this.sepaDirectDebitApi = sepaDirectDebitApi;
}

/**
* Initiates a browser switch to display a mandate to the user. Upon successful mandate
* creation, tokenizes the payment method and returns a result to the
* {@link SEPADirectDebitListener}.
* Starts the SEPA tokenization process by creating a {@link SEPADirectDebitResponse} to be used
* to launch the SEPA mandate flow in
* {@link SEPADirectDebitLauncher#launch(FragmentActivity, SEPADirectDebitResponse)}
*
* @param activity an Android FragmentActivity
* @param sepaDirectDebitRequest the {@link SEPADirectDebitRequest}.
* @param sepaDirectDebitRequest {@link SEPADirectDebitRequest}
* @param callback {@link SEPADirectDebitFlowStartedCallback}
*/
public void tokenize(final FragmentActivity activity,
final SEPADirectDebitRequest sepaDirectDebitRequest) {
public void tokenize(@NonNull final SEPADirectDebitRequest sepaDirectDebitRequest,
@NonNull final SEPADirectDebitFlowStartedCallback callback) {
braintreeClient.sendAnalyticsEvent("sepa-direct-debit.selected.started");
braintreeClient.sendAnalyticsEvent("sepa-direct-debit.create-mandate.requested");
sepaDirectDebitApi.createMandate(sepaDirectDebitRequest,
Expand All @@ -94,11 +63,13 @@ public void tokenize(final FragmentActivity activity,
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.create-mandate.success");
try {
startBrowserSwitch(activity, result);
} catch (JSONException | BrowserSwitchException exception) {
SEPADirectDebitResponse sepaDirectDebitResponse =
new SEPADirectDebitResponse(buildBrowserSwitchOptions(result), null);
callback.onResult(sepaDirectDebitResponse, null);
} catch (JSONException exception) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.browser-switch.failure");
listener.onSEPADirectDebitFailure(exception);
callback.onResult(null, exception);
}
} else if (result.getApprovalUrl().equals("null")) {
braintreeClient.sendAnalyticsEvent(
Expand All @@ -113,37 +84,58 @@ public void tokenize(final FragmentActivity activity,
if (sepaDirectDebitNonce != null) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.tokenize.success");
listener.onSEPADirectDebitSuccess(sepaDirectDebitNonce);
SEPADirectDebitResponse sepaDirectDebitResponse =
new SEPADirectDebitResponse(null, sepaDirectDebitNonce);
callback.onResult(sepaDirectDebitResponse, null);
} else if (tokenizeError != null) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.tokenize.failure");
listener.onSEPADirectDebitFailure(tokenizeError);
callback.onResult(null, tokenizeError);
}
});
} else {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.create-mandate.failure");
listener.onSEPADirectDebitFailure(
new BraintreeException("An unexpected error occurred."));
callback.onResult(null, new BraintreeException("An unexpected error occurred."));
}
} else if (createMandateError != null) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.create-mandate.failure");
listener.onSEPADirectDebitFailure(createMandateError);
callback.onResult(null, createMandateError);
}
});
}

void onBrowserSwitchResult(FragmentActivity activity) {
// TODO: - The wording in this docstring is confusing to me. Let's improve & align across all clients.
/**
* After receiving a result from the SEPA mandate web flow via
* {@link SEPADirectDebitLauncher#handleReturnToAppFromBrowser(Context, Intent)}, pass the
* {@link SEPADirectDebitBrowserSwitchResult} returned to this method to tokenize the SEPA
* account and receive a {@link SEPADirectDebitNonce} on success.
*
* @param sepaDirectDebitBrowserSwitchResult a {@link SEPADirectDebitBrowserSwitchResult} received
* in the callback of {@link SEPADirectDebitLauncher}
* @param callback {@link SEPADirectDebitBrowserSwitchResultCallback}
*/
public void onBrowserSwitchResult(@NonNull SEPADirectDebitBrowserSwitchResult sepaDirectDebitBrowserSwitchResult,
@NonNull final SEPADirectDebitBrowserSwitchResultCallback callback) {
BrowserSwitchResult browserSwitchResult =
braintreeClient.deliverBrowserSwitchResult(activity);
sepaDirectDebitBrowserSwitchResult.getBrowserSwitchResult();
if (browserSwitchResult == null && sepaDirectDebitBrowserSwitchResult.getError() != null) {
callback.onResult(null, sepaDirectDebitBrowserSwitchResult.getError());
return;
}

if (browserSwitchResult == null) {
callback.onResult(null, new BraintreeException("An unexpected error occurred."));
return;
}

int result = browserSwitchResult.getStatus();
switch (result) {
case BrowserSwitchStatus.CANCELED:
braintreeClient.sendAnalyticsEvent("sepa-direct-debit.browser-switch.canceled");
listener.onSEPADirectDebitFailure(
new UserCanceledException("User canceled SEPA Debit."));
callback.onResult(null, new UserCanceledException("User canceled SEPA Debit."));
break;
case BrowserSwitchStatus.SUCCESS:
Uri deepLinkUri = browserSwitchResult.getDeepLinkUrl();
Expand All @@ -165,34 +157,27 @@ void onBrowserSwitchResult(FragmentActivity activity) {
if (sepaDirectDebitNonce != null) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.tokenize.success");
listener.onSEPADirectDebitSuccess(sepaDirectDebitNonce);
callback.onResult(sepaDirectDebitNonce, null);
} else if (error != null) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.tokenize.failure");
listener.onSEPADirectDebitFailure(error);
callback.onResult(null, error);
}
});
} else if (deepLinkUri.getPath().contains("cancel")) {
braintreeClient.sendAnalyticsEvent(
"sepa-direct-debit.browser-switch.failure");
listener.onSEPADirectDebitFailure(
new BraintreeException("An unexpected error occurred."));
callback.onResult(null, new BraintreeException("An unexpected error occurred."));
}
} else {
braintreeClient.sendAnalyticsEvent("sepa-direct-debit.browser-switch.failure");
listener.onSEPADirectDebitFailure(new BraintreeException("Unknown error"));
callback.onResult(null, new BraintreeException("Unknown error"));
}
break;
}
}

BrowserSwitchResult getBrowserSwitchResult(FragmentActivity activity) {
return braintreeClient.getBrowserSwitchResult(activity);
}

private void startBrowserSwitch(FragmentActivity activity,
CreateMandateResult createMandateResult)
throws JSONException, BrowserSwitchException {
private BrowserSwitchOptions buildBrowserSwitchOptions(CreateMandateResult createMandateResult) throws JSONException {
JSONObject metadata = new JSONObject()
.put(IBAN_LAST_FOUR_KEY, createMandateResult.getIbanLastFour())
.put(CUSTOMER_ID_KEY, createMandateResult.getCustomerId())
Expand All @@ -205,7 +190,6 @@ private void startBrowserSwitch(FragmentActivity activity,
.metadata(metadata)
.returnUrlScheme(braintreeClient.getReturnUrlScheme());

braintreeClient.startBrowserSwitch(activity, browserSwitchOptions);
braintreeClient.sendAnalyticsEvent("sepa-direct-debit.browser-switch.started");
return browserSwitchOptions;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.braintreepayments.api;

import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;

/**
* Callback for receiving result of
* {@link SEPADirectDebitClient#tokenize(SEPADirectDebitRequest, SEPADirectDebitFlowStartedCallback)}.
*/
public interface SEPADirectDebitFlowStartedCallback {

/**
* @param sepaDirectDebitResponse the result of the SEPA create mandate call. If a nonce is present,
* no web-based mandate is required. If a nonce is not present,
* you must trigger the web-based mandate flow via
* {@link SEPADirectDebitLauncher#launch(FragmentActivity, SEPADirectDebitResponse)}
* @param error an exception that occurred while initiating the SEPA transaction
*/
void onResult(@Nullable SEPADirectDebitResponse sepaDirectDebitResponse, @Nullable Exception error);
}
Loading

0 comments on commit 785b207

Please sign in to comment.