From 5c464a0ef019cda73ca91ee61bf1a6c4f8e90046 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Mon, 19 Jul 2021 13:49:42 +1000 Subject: [PATCH 01/68] Move Configuration to public model --- afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt | 2 +- .../com/afterpay/android/internal/AfterpayCheckoutV2.kt | 1 + .../com/afterpay/android/internal/AfterpayInstalment.kt | 1 + .../com/afterpay/android/internal/ConfigurationObservable.kt | 1 + .../com/afterpay/android/{internal => model}/Configuration.kt | 4 ++-- .../kotlin/com/afterpay/android/AfterpayInstalmentTest.kt | 2 +- 6 files changed, 7 insertions(+), 4 deletions(-) rename afterpay/src/main/kotlin/com/afterpay/android/{internal => model}/Configuration.kt (80%) diff --git a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt index c056c903..d47d2739 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt @@ -2,7 +2,7 @@ package com.afterpay.android import android.content.Context import android.content.Intent -import com.afterpay.android.internal.Configuration +import com.afterpay.android.model.Configuration import com.afterpay.android.internal.ConfigurationObservable import com.afterpay.android.internal.Locales import com.afterpay.android.internal.getCancellationStatusExtra diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayCheckoutV2.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayCheckoutV2.kt index 29a4db8e..d01c6668 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayCheckoutV2.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayCheckoutV2.kt @@ -2,6 +2,7 @@ package com.afterpay.android.internal import com.afterpay.android.AfterpayCheckoutV2Options import com.afterpay.android.BuildConfig +import com.afterpay.android.model.Configuration import kotlinx.serialization.Serializable @Serializable diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayInstalment.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayInstalment.kt index a49ed233..98e02fde 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayInstalment.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/AfterpayInstalment.kt @@ -1,5 +1,6 @@ package com.afterpay.android.internal +import com.afterpay.android.model.Configuration import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/ConfigurationObservable.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/ConfigurationObservable.kt index ed60a969..099514d1 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/ConfigurationObservable.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/ConfigurationObservable.kt @@ -1,5 +1,6 @@ package com.afterpay.android.internal +import com.afterpay.android.model.Configuration import java.util.Observable internal object ConfigurationObservable : Observable() { diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/Configuration.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/Configuration.kt similarity index 80% rename from afterpay/src/main/kotlin/com/afterpay/android/internal/Configuration.kt rename to afterpay/src/main/kotlin/com/afterpay/android/model/Configuration.kt index e13f2708..ba316a0b 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/Configuration.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/Configuration.kt @@ -1,11 +1,11 @@ -package com.afterpay.android.internal +package com.afterpay.android.model import com.afterpay.android.AfterpayEnvironment import java.math.BigDecimal import java.util.Currency import java.util.Locale -internal data class Configuration( +data class Configuration( val minimumAmount: BigDecimal?, val maximumAmount: BigDecimal, val currency: Currency, diff --git a/afterpay/src/test/kotlin/com/afterpay/android/AfterpayInstalmentTest.kt b/afterpay/src/test/kotlin/com/afterpay/android/AfterpayInstalmentTest.kt index c0a75f58..3be014b9 100644 --- a/afterpay/src/test/kotlin/com/afterpay/android/AfterpayInstalmentTest.kt +++ b/afterpay/src/test/kotlin/com/afterpay/android/AfterpayInstalmentTest.kt @@ -1,7 +1,7 @@ package com.afterpay.android import com.afterpay.android.internal.AfterpayInstalment -import com.afterpay.android.internal.Configuration +import com.afterpay.android.model.Configuration import com.afterpay.android.internal.Locales import org.junit.Assert.assertEquals import org.junit.Test From 30a6324dcdfcf6905665541a2f94af2c2b10f6ec Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Mon, 19 Jul 2021 13:50:57 +1000 Subject: [PATCH 02/68] Add most required models and interfaces for V3 functionality --- .../kotlin/com/afterpay/android/Afterpay.kt | 118 +++++++++++++--- .../com/afterpay/android/internal/ApiV3.kt | 127 ++++++++++++++++++ .../afterpay/android/internal/CheckoutV3.kt | 14 ++ .../afterpay/android/model/AfterpayRegion.kt | 15 +++ .../android/model/CheckoutV3Configuration.kt | 44 ++++++ .../android/model/CheckoutV3Consumer.kt | 16 +++ .../android/model/CheckoutV3Contact.kt | 24 ++++ .../afterpay/android/model/CheckoutV3Item.kt | 27 ++++ .../android/model/CheckoutV3Tokens.kt | 10 ++ .../android/model/MerchantConfigurationV3.kt | 9 ++ .../com/afterpay/android/model/OrderTotal.kt | 9 ++ .../android/view/AfterpayWidgetView.kt | 2 +- 12 files changed, 399 insertions(+), 16 deletions(-) create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/AfterpayRegion.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Consumer.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Contact.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Item.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Tokens.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/MerchantConfigurationV3.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/model/OrderTotal.kt diff --git a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt index d47d2739..b63d29c4 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt @@ -2,6 +2,9 @@ package com.afterpay.android import android.content.Context import android.content.Intent +import com.afterpay.android.internal.ApiV3 +import com.afterpay.android.internal.CheckoutV3 +import com.afterpay.android.model.CheckoutV3Tokens import com.afterpay.android.model.Configuration import com.afterpay.android.internal.ConfigurationObservable import com.afterpay.android.internal.Locales @@ -9,6 +12,11 @@ import com.afterpay.android.internal.getCancellationStatusExtra import com.afterpay.android.internal.getOrderTokenExtra import com.afterpay.android.internal.putCheckoutUrlExtra import com.afterpay.android.internal.putCheckoutV2OptionsExtra +import com.afterpay.android.model.CheckoutV3Configuration +import com.afterpay.android.model.CheckoutV3Consumer +import com.afterpay.android.model.CheckoutV3Item +import com.afterpay.android.model.MerchantConfigurationV3 +import com.afterpay.android.model.OrderTotal import com.afterpay.android.view.AfterpayCheckoutActivity import com.afterpay.android.view.AfterpayCheckoutV2Activity import java.math.BigDecimal @@ -92,23 +100,37 @@ object Afterpay { currency = Currency.getInstance(currencyCode), locale = locale.clone() as Locale, environment = environment - ).also { configuration -> - if (configuration.maximumAmount < BigDecimal.ZERO) { - throw IllegalArgumentException("Maximum order amount is invalid") - } - configuration.minimumAmount?.let { minimumAmount -> - if (minimumAmount < BigDecimal.ZERO || minimumAmount > configuration.maximumAmount) { - throw IllegalArgumentException("Minimum order amount is invalid") - } - } - if (!Locales.validSet.contains(configuration.locale)) { - val validCountries = Locales.validSet.map { it.country } - throw IllegalArgumentException( - "Locale contains an unsupported country: ${configuration.locale.country}. " + - "Supported countries include: ${validCountries.joinToString(",")}" - ) + ).also { validateConfiguration(it) } + } + + /** + * Sets the global checkout configuration object. + * + * Results in a [NumberFormatException] if an amount is not a valid representation of a number + * or an [IllegalArgumentException] if the currency is not a valid ISO 4217 currency code, if + * the minimum and maximum amount isn't correctly ordered, or if the locale is not supported. + */ + @JvmStatic + fun setConfigurationV3(newConfiguration: Configuration) { + configuration = newConfiguration.also { validateConfiguration(it) } + } + + private fun validateConfiguration(configuration: Configuration) { + if (configuration.maximumAmount < BigDecimal.ZERO) { + throw IllegalArgumentException("Maximum order amount is invalid") + } + configuration.minimumAmount?.let { minimumAmount -> + if (minimumAmount < BigDecimal.ZERO || minimumAmount > configuration.maximumAmount) { + throw IllegalArgumentException("Minimum order amount is invalid") } } + if (!Locales.validSet.contains(configuration.locale)) { + val validCountries = Locales.validSet.map { it.country } + throw IllegalArgumentException( + "Locale contains an unsupported country: ${configuration.locale.country}. " + + "Supported countries include: ${validCountries.joinToString(",")}" + ) + } } /** @@ -118,4 +140,70 @@ object Afterpay { fun setCheckoutV2Handler(handler: AfterpayCheckoutV2Handler?) { checkoutV2Handler = handler } + + // V3 work + + private var checkoutV3Configuration: CheckoutV3Configuration? = null + + @JvmStatic + fun setCheckoutV3Configuration(configuration: CheckoutV3Configuration) { + checkoutV3Configuration = configuration + } + + @JvmStatic + suspend fun updateMerchantReferenceV3( + merchantReference: String, + tokens: CheckoutV3Tokens, + configuration: CheckoutV3Configuration? = checkoutV3Configuration + ): Result { + val configuration = configuration + ?: throw IllegalArgumentException("`configuration` must be set via `setCheckoutV3Configuration` or passed into this function") + + val payload = CheckoutV3.MerchantReferenceUpdate( + merchantReference, + token = tokens.token, + ppaConfirmToken = tokens.ppaConfirmToken, + singleUseCardToken = tokens.singleUseCardToken + ) + + return ApiV3.request( + configuration.v3CheckoutUrl, + ApiV3.HttpVerb.PUT, + payload + ) + } + + @JvmStatic + suspend fun fetchMerchantConfigurationV3( + configuration: CheckoutV3Configuration? = checkoutV3Configuration + ): Result { + val configuration = configuration + ?: throw IllegalArgumentException("`configuration` must be set via `setCheckoutV3Configuration` or passed into this function") + + return ApiV3.get(configuration.v3ConfigurationUrl) + .map { + Configuration( + minimumAmount = it.minimumAmount.amount, + maximumAmount = it.maximumAmount.amount, + currency = Currency.getInstance(configuration.region.currencyCode), + locale = configuration.region.locale, + environment = configuration.environment + ) + } + } + + @JvmStatic + fun createCheckoutIntentV3( + context: Context, + consumer: CheckoutV3Consumer, + orderTotal: OrderTotal, + items: Array = arrayOf(), + buyNow: Boolean, + configuration: CheckoutV3Configuration? = checkoutV3Configuration + ): Intent { + val configuration = configuration + ?: throw IllegalArgumentException("`configuration` must be set via `setCheckoutV3Configuration` or passed into this function") + val intent = Intent(context, AfterpayCheckoutActivity::class.java) + return intent + } } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt new file mode 100644 index 00000000..c4ca2ffe --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/ApiV3.kt @@ -0,0 +1,127 @@ +package com.afterpay.android.internal + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* +import java.io.OutputStreamWriter +import java.lang.Exception +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +object ApiV3 { + // @JvmStatic + // internal suspend inline fun request(url: URL, method: HttpVerb, body: B, crossinline completion: (Result) -> Unit) { + // val connection = url.openConnection() as HttpsURLConnection + // try { + // configure(connection, method) + // + // val payload = Json.encodeToString(body) + // val outputStreamWriter = OutputStreamWriter(connection.outputStream) + // outputStreamWriter.write(payload) + // outputStreamWriter.flush() + // + // // TODO: Status code checking, error object decoding + // val inputStream = connection.inputStream.bufferedReader().readText() + // val result = Json.decodeFromString(inputStream) + // withContext(Dispatchers.Main) { + // completion(Result.success(result)) + // } + // } catch (error: Exception) { + // withContext(Dispatchers.Main) { + // completion(Result.failure(error)) + // } + // } finally { + // connection.disconnect() + // } + // } + + @JvmStatic + internal inline fun request(url: URL, method: HttpVerb, body: B): Result { + val connection = url.openConnection() as HttpsURLConnection + return try { + configure(connection, method) + + val payload = Json.encodeToString(body) + val outputStreamWriter = OutputStreamWriter(connection.outputStream) + outputStreamWriter.write(payload) + outputStreamWriter.flush() + + // TODO: Status code checking, error object decoding, bypass if return type is Unit + val inputStream = connection.inputStream.bufferedReader().readText() + val result = Json.decodeFromString(inputStream) + Result.success(result) + } catch (error: Exception) { + Result.failure(error) + } finally { + connection.disconnect() + } + } + + // @JvmStatic + // internal suspend inline fun get(url: URL, crossinline completion: (Result) -> Unit) { + // val connection = url.openConnection() as HttpsURLConnection + // try { + // configure(connection, HttpVerb.GET) + // connection.setChunkedStreamingMode(0) + // + // val inputStream = connection.inputStream.bufferedReader().readText() + // val result = Json.decodeFromString(inputStream) + // withContext(Dispatchers.Main) { + // completion(Result.success(result)) + // } + // } catch (error: Exception) { + // withContext(Dispatchers.Main) { + // completion(Result.failure(error)) + // } + // } finally { + // connection.disconnect() + // } + // } + + @JvmStatic + internal inline fun get(url: URL): Result { + val connection = url.openConnection() as HttpsURLConnection + return try { + configure(connection, HttpVerb.GET) + connection.setChunkedStreamingMode(0) + + val inputStream = connection.inputStream.bufferedReader().readText() + val result = Json.decodeFromString(inputStream) + Result.success(result) + } catch (error: Exception) { + Result.failure(error) + } finally { + connection.disconnect() + } + } + + internal enum class HttpVerb(name: String) { + POST("POST"), PUT("PUT"), GET("GET") + } + + private fun configure(connection: HttpsURLConnection, type: HttpVerb) { + connection.requestMethod = type.name + // TODO: SDK version like on iOS? + connection.setRequestProperty("1.3", "X-Afterpay-SDK") + when (type) { + HttpVerb.POST, HttpVerb.PUT -> { + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Accept", "application/json") + } + } + when (type) { + HttpVerb.GET -> { + connection.doInput = true + connection.doOutput = false + } + HttpVerb.PUT -> { + connection.doInput = true + connection.doOutput = false // TODO: What? + } + HttpVerb.POST -> { + connection.doInput = true + connection.doOutput = true + } + } + } +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt new file mode 100644 index 00000000..6451d361 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt @@ -0,0 +1,14 @@ +package com.afterpay.android.internal + +import com.afterpay.android.model.CheckoutV3Tokens +import kotlinx.serialization.Serializable + +object CheckoutV3 { + @Serializable + data class MerchantReferenceUpdate( + val merchantReference: String, + val token: String, + val singleUseCardToken: String, + val ppaConfirmToken: String + ) +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/AfterpayRegion.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/AfterpayRegion.kt new file mode 100644 index 00000000..78ca939a --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/AfterpayRegion.kt @@ -0,0 +1,15 @@ +package com.afterpay.android.model + +import com.afterpay.android.internal.Locales +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Locale + +enum class AfterpayRegion(val locale: Locale, val currencyCode: String) { + US(Locales.US, "USD") +} + +fun AfterpayRegion.formatted(currency: BigDecimal): String { + // Round to two decimals, as per ISO-4217, using banker's rounding + return currency.setScale(2, RoundingMode.HALF_EVEN).toString() +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt new file mode 100644 index 00000000..0b54f124 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt @@ -0,0 +1,44 @@ +package com.afterpay.android.model + +import android.net.Uri +import com.afterpay.android.AfterpayEnvironment +import java.net.URL + +data class CheckoutV3Configuration( + val shopDirectoryMerchantId: String, + val region: AfterpayRegion, + val environment: AfterpayEnvironment +) { + private val shopDirectoryId: String + get() = when (region) { + AfterpayRegion.US -> when (environment) { + AfterpayEnvironment.SANDBOX -> "cd6b7914412b407d80aaf81d855d1105" + AfterpayEnvironment.PRODUCTION -> "e1e5632bebe64cee8e5daff8588e8f2f05ca4ed6ac524c76824c04e09033badc" + } + } + + private val baseUrl: String + get() = when (region) { + AfterpayRegion.US -> when (environment) { + AfterpayEnvironment.SANDBOX -> "https://api-plus.us-sandbox.afterpay.com/v3/button" + AfterpayEnvironment.PRODUCTION -> "https://api-plus.us.afterpay.com/v3/button" + } + } + + val v3CheckoutUrl: URL + get() = URL(baseUrl) + + val v3CheckoutConfirmationUrl: URL + get() = URL("$baseUrl/confirm") + + val v3ConfigurationUrl: URL + get() { + val url = "$baseUrl/merchant/config" + val builder = Uri.parse(url) + .buildUpon() + .appendQueryParameter("shopDirectoryId", shopDirectoryId) + .appendQueryParameter("shopDirectoryMerchantId", shopDirectoryMerchantId) + .build() + return URL(builder.toString()) + } +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Consumer.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Consumer.kt new file mode 100644 index 00000000..43a4c584 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Consumer.kt @@ -0,0 +1,16 @@ +package com.afterpay.android.model + +interface CheckoutV3Consumer { + /** The consumer’s email address. Limited to 128 characters. **/ + var email: String + /** The consumer’s first name and any middle names. Limited to 128 characters. **/ + var givenNames: String? + /** The consumer’s last name. Limited to 128 characters. **/ + var surname: String? + /** The consumer’s phone number. Limited to 32 characters. **/ + var phoneNumber: String? + /** The consumer's shipping information. **/ + var shippingInformation: CheckoutV3Contact? + /** The consumer's billing information. **/ + var billingInformation: CheckoutV3Contact? +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Contact.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Contact.kt new file mode 100644 index 00000000..5f8681fb --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Contact.kt @@ -0,0 +1,24 @@ +package com.afterpay.android.model + +interface CheckoutV3Contact { + /** Full name of contact. Limited to 255 characters */ + var name: String + /** First line of the address. Limited to 128 characters */ + var line1: String + /** Second line of the address. Limited to 128 characters. */ + var line2: String? + /** Australian suburb, U.S. city, New Zealand town or city, U.K. Postal town. + * Maximum length is 128 characters. + */ + var area1: String? + /** New Zealand suburb, U.K. village or local area. Maximum length is 128 characters. */ + var area2: String? + /** U.S. state, Australian state, U.K. county, New Zealand region. Maximum length is 128 characters. */ + var region: String? + /** The zip code or equivalent. Maximum length is 128 characters. */ + var postcode: String? + /** The two-character ISO 3166-1 country code. */ + var countryCode: String + /** The phone number, in E.123 format. Maximum length is 32 characters. */ + var phoneNumber: String? +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Item.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Item.kt new file mode 100644 index 00000000..1840ddd3 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Item.kt @@ -0,0 +1,27 @@ +package com.afterpay.android.model + +import java.math.BigDecimal +import java.net.URL + +interface CheckoutV3Item { + /** Product name. Limited to 255 characters. */ + var name: String + /** The quantity of the item, stored as a signed 32-bit integer. */ + var quantity: UInt + /** The unit price of the individual item. Must be a positive value. */ + var price: BigDecimal + /** Product SKU. Limited to 128 characters. */ + var sku: String? + /** The canonical URL for the item's Product Detail Page. Limited to 2048 characters. */ + var pageUrl: URL? + /** A URL for a web-optimised photo of the item, suitable for use directly as the src attribute of an img tag. + * Limited to 2048 characters. + */ + var imageUrl: URL? + /** An array of arrays to accommodate multiple categories that might apply to the item. + * Each array contains comma separated strings with the left-most category being the top level category. + */ + var categories: Array>? + /** The estimated date when the order will be shipped. YYYY-MM or YYYY-MM-DD format. */ + var estimatedShipmentDate: String? +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Tokens.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Tokens.kt new file mode 100644 index 00000000..0a4ddf17 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Tokens.kt @@ -0,0 +1,10 @@ +package com.afterpay.android.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CheckoutV3Tokens( + val token: String, + val singleUseCardToken: String, + val ppaConfirmToken: String +) diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/MerchantConfigurationV3.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/MerchantConfigurationV3.kt new file mode 100644 index 00000000..53308d17 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/MerchantConfigurationV3.kt @@ -0,0 +1,9 @@ +package com.afterpay.android.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MerchantConfigurationV3( + val minimumAmount: Money, + val maximumAmount: Money +) diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/OrderTotal.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/OrderTotal.kt new file mode 100644 index 00000000..33309564 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/OrderTotal.kt @@ -0,0 +1,9 @@ +package com.afterpay.android.model + +import java.math.BigDecimal + +data class OrderTotal( + val total: BigDecimal, + val shipping: BigDecimal, + val tax: BigDecimal +) diff --git a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayWidgetView.kt b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayWidgetView.kt index f77a29bc..6d7c526d 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayWidgetView.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayWidgetView.kt @@ -15,7 +15,7 @@ import android.webkit.WebViewClient import androidx.annotation.RequiresApi import com.afterpay.android.Afterpay import com.afterpay.android.R -import com.afterpay.android.internal.Configuration +import com.afterpay.android.model.Configuration import com.afterpay.android.internal.setAfterpayUserAgentString import com.afterpay.android.model.Money import kotlinx.coroutines.CoroutineScope From cce19087ea456e517148a1dc9be69bb39f0e5841 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Mon, 19 Jul 2021 13:51:29 +1000 Subject: [PATCH 03/68] Replace V1/V2 setup and configuration with V3 --- .../com/example/afterpay/MainActivity.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/example/src/main/kotlin/com/example/afterpay/MainActivity.kt b/example/src/main/kotlin/com/example/afterpay/MainActivity.kt index 8363bb6e..f8810973 100644 --- a/example/src/main/kotlin/com/example/afterpay/MainActivity.kt +++ b/example/src/main/kotlin/com/example/afterpay/MainActivity.kt @@ -13,6 +13,8 @@ import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController import com.afterpay.android.Afterpay import com.afterpay.android.AfterpayEnvironment +import com.afterpay.android.model.AfterpayRegion +import com.afterpay.android.model.CheckoutV3Configuration import com.example.afterpay.checkout.CheckoutFragment import com.example.afterpay.data.AfterpayRepository import com.example.afterpay.receipt.ReceiptFragment @@ -101,17 +103,18 @@ class MainActivity : AppCompatActivity() { private suspend fun applyAfterpayConfiguration(forceRefresh: Boolean = false) { try { - val configuration = withContext(Dispatchers.IO) { - afterpayRepository.fetchConfiguration(forceRefresh) - } - - Afterpay.setConfiguration( - minimumAmount = configuration.minimumAmount, - maximumAmount = configuration.maximumAmount, - currencyCode = configuration.currency, - locale = Locale(configuration.language, configuration.country), + val afterpayConfigV3 = CheckoutV3Configuration( + shopDirectoryMerchantId = "822ce7ffc2fa41258904baad1d0fe07351e89375108949e8bd951d387ef0e932", + region = AfterpayRegion.US, environment = AfterpayEnvironment.SANDBOX ) + Afterpay.setCheckoutV3Configuration(afterpayConfigV3) + + val merchantConfig = withContext(Dispatchers.IO) { + Afterpay.fetchMerchantConfigurationV3() + }.getOrThrow() + + Afterpay.setConfigurationV3(merchantConfig) } catch (e: Exception) { Snackbar .make( From 9c5f3ee43f9d363debd553b777c84ceb6b279e02 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Mon, 19 Jul 2021 15:02:54 +1000 Subject: [PATCH 04/68] Add models and interfaces required to inform V3 checkout --- .../kotlin/com/afterpay/android/Afterpay.kt | 19 +- .../android/AfterpayCheckoutV3Options.kt | 42 ++++ .../afterpay/android/internal/CheckoutV3.kt | 134 +++++++++++- .../com/afterpay/android/internal/Intent.kt | 7 + .../android/model/CheckoutV3Configuration.kt | 2 +- .../view/AfterpayCheckoutV3Activity.kt | 193 ++++++++++++++++++ 6 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/AfterpayCheckoutV3Options.kt create mode 100644 afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt diff --git a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt index b63d29c4..8890191b 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/Afterpay.kt @@ -12,6 +12,7 @@ import com.afterpay.android.internal.getCancellationStatusExtra import com.afterpay.android.internal.getOrderTokenExtra import com.afterpay.android.internal.putCheckoutUrlExtra import com.afterpay.android.internal.putCheckoutV2OptionsExtra +import com.afterpay.android.internal.putCheckoutV3OptionsExtra import com.afterpay.android.model.CheckoutV3Configuration import com.afterpay.android.model.CheckoutV3Consumer import com.afterpay.android.model.CheckoutV3Item @@ -19,6 +20,8 @@ import com.afterpay.android.model.MerchantConfigurationV3 import com.afterpay.android.model.OrderTotal import com.afterpay.android.view.AfterpayCheckoutActivity import com.afterpay.android.view.AfterpayCheckoutV2Activity +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.math.BigDecimal import java.util.Currency import java.util.Locale @@ -203,7 +206,19 @@ object Afterpay { ): Intent { val configuration = configuration ?: throw IllegalArgumentException("`configuration` must be set via `setCheckoutV3Configuration` or passed into this function") - val intent = Intent(context, AfterpayCheckoutActivity::class.java) - return intent + + val checkoutRequest = CheckoutV3.Request.create( + consumer = consumer, + orderTotal = orderTotal, + items = items, + configuration = configuration, + ) + val options = AfterpayCheckoutV3Options( + buyNow = buyNow, + checkoutPayload = Json.encodeToString(checkoutRequest) + ) + + return Intent(context, AfterpayCheckoutActivity::class.java) + .putCheckoutV3OptionsExtra(options) } } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/AfterpayCheckoutV3Options.kt b/afterpay/src/main/kotlin/com/afterpay/android/AfterpayCheckoutV3Options.kt new file mode 100644 index 00000000..af7d83a4 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/AfterpayCheckoutV3Options.kt @@ -0,0 +1,42 @@ +package com.afterpay.android + +import android.os.Parcel +import android.os.Parcelable + +data class AfterpayCheckoutV3Options( + val buyNow: Boolean? = null, + val checkoutPayload: String? = null, + val token: String? = null, + val ppaConfirmToken: String? = null, + val singleUseCardToken: String? = null, +) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readValue(Boolean::class.java.classLoader) as? Boolean, + parcel.readValue(String::class.java.classLoader) as? String, + parcel.readValue(String::class.java.classLoader) as? String, + parcel.readValue(String::class.java.classLoader) as? String, + parcel.readValue(String::class.java.classLoader) as? String + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeValue(buyNow) + parcel.writeValue(checkoutPayload) + parcel.writeValue(token) + parcel.writeValue(ppaConfirmToken) + parcel.writeValue(singleUseCardToken) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): AfterpayCheckoutV3Options { + return AfterpayCheckoutV3Options(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt index 6451d361..9873656d 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/CheckoutV3.kt @@ -1,9 +1,16 @@ package com.afterpay.android.internal -import com.afterpay.android.model.CheckoutV3Tokens +import com.afterpay.android.model.AfterpayRegion +import com.afterpay.android.model.CheckoutV3Configuration +import com.afterpay.android.model.CheckoutV3Consumer +import com.afterpay.android.model.CheckoutV3Contact +import com.afterpay.android.model.CheckoutV3Item +import com.afterpay.android.model.Money +import com.afterpay.android.model.OrderTotal import kotlinx.serialization.Serializable +import java.util.Currency -object CheckoutV3 { +internal object CheckoutV3 { @Serializable data class MerchantReferenceUpdate( val merchantReference: String, @@ -11,4 +18,127 @@ object CheckoutV3 { val singleUseCardToken: String, val ppaConfirmToken: String ) + + @Serializable + data class Request( + val shopDirectoryId: String, + val shopDirectoryMerchantId: String, + + val amount: Money, + val shippingAmount: Money?, + val taxAmount: Money?, + + val items: List, + val consumer: Consumer, + val merchant: Merchant, + val shipping: Contact?, + val billing: Contact? + ) { + companion object { + @JvmStatic + fun create( + consumer: CheckoutV3Consumer, + orderTotal: OrderTotal, + items: Array, + configuration: CheckoutV3Configuration + ): Request { + val currency = Currency.getInstance(configuration.region.currencyCode) + + return Request( + shopDirectoryId = configuration.shopDirectoryId, + shopDirectoryMerchantId = configuration.shopDirectoryMerchantId, + amount = Money(orderTotal.total, currency), + shippingAmount = Money(orderTotal.shipping, currency), + taxAmount = Money(orderTotal.tax, currency), + items = items.map { Item.create(it, configuration.region) }, + consumer = Consumer( + email = consumer.email, + givenNames = consumer.givenNames, + surname = consumer.surname, + phoneNumber = consumer.phoneNumber + ), + merchant = Merchant( + redirectConfirmUrl = "https://www.afterpay.com", + redirectCancelUrl = "https://www.afterpay.com" + ), + shipping = Contact.create(consumer.shippingInformation), + billing = Contact.create(consumer.billingInformation) + ) + } + } + } + + @Serializable + data class Item( + val name: String, + val quantity: UInt, + val price: Money, + val sku: String?, + val pageUrl: String?, + val imageUrl: String?, + val categories: Array>?, + val estimatedShipmentDate: String? + ) { + companion object { + @JvmStatic + fun create(item: CheckoutV3Item, region: AfterpayRegion): Item { + val currency = Currency.getInstance(region.currencyCode) + return Item( + name = item.name, + quantity = item.quantity, + price = Money(item.price, currency), + sku = item.sku, + pageUrl = item.pageUrl.toString(), + imageUrl = item.imageUrl.toString(), + categories = item.categories, + estimatedShipmentDate = item.estimatedShipmentDate + ) + } + } + } + + @Serializable + data class Merchant( + val redirectConfirmUrl: String, + val redirectCancelUrl: String + ) + + @Serializable + data class Consumer( + val email: String, + val givenNames: String?, + val surname: String?, + val phoneNumber: String? + ) + + @Serializable + data class Contact( + val name: String, + val line1: String, + val line2: String?, + val area1: String?, + val area2: String?, + val region: String?, + val postcode: String?, + val countryCode: String, + val phoneNumber: String? + ) { + companion object { + @JvmStatic + fun create(contact: CheckoutV3Contact?): Contact? { + val contact = contact ?: return null + return Contact( + name = contact.name, + line1 = contact.line1, + line2 = contact.line2, + area1 = contact.area1, + area2 = contact.area2, + region = contact.region, + postcode = contact.postcode, + countryCode = contact.countryCode, + phoneNumber = contact.phoneNumber + ) + } + } + } } diff --git a/afterpay/src/main/kotlin/com/afterpay/android/internal/Intent.kt b/afterpay/src/main/kotlin/com/afterpay/android/internal/Intent.kt index 181af35f..1e929704 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/internal/Intent.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/internal/Intent.kt @@ -2,6 +2,7 @@ package com.afterpay.android.internal import android.content.Intent import com.afterpay.android.AfterpayCheckoutV2Options +import com.afterpay.android.AfterpayCheckoutV3Options import com.afterpay.android.CancellationStatus import java.lang.Exception @@ -25,6 +26,12 @@ internal fun Intent.putCheckoutV2OptionsExtra(options: AfterpayCheckoutV2Options internal fun Intent.getCheckoutV2OptionsExtra(): AfterpayCheckoutV2Options? = getParcelableExtra(AfterpayIntent.CHECKOUT_OPTIONS) +internal fun Intent.putCheckoutV3OptionsExtra(options: AfterpayCheckoutV3Options): Intent = + putExtra(AfterpayIntent.CHECKOUT_OPTIONS, options) + +internal fun Intent.getCheckoutV3OptionsExtra(): AfterpayCheckoutV3Options? = + getParcelableExtra(AfterpayIntent.CHECKOUT_OPTIONS) + internal fun Intent.putOrderTokenExtra(token: String): Intent = putExtra(AfterpayIntent.ORDER_TOKEN, token) diff --git a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt index 0b54f124..48d22d4c 100644 --- a/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt +++ b/afterpay/src/main/kotlin/com/afterpay/android/model/CheckoutV3Configuration.kt @@ -9,7 +9,7 @@ data class CheckoutV3Configuration( val region: AfterpayRegion, val environment: AfterpayEnvironment ) { - private val shopDirectoryId: String + internal val shopDirectoryId: String get() = when (region) { AfterpayRegion.US -> when (environment) { AfterpayEnvironment.SANDBOX -> "cd6b7914412b407d80aaf81d855d1105" diff --git a/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt new file mode 100644 index 00000000..be768c68 --- /dev/null +++ b/afterpay/src/main/kotlin/com/afterpay/android/view/AfterpayCheckoutV3Activity.kt @@ -0,0 +1,193 @@ +package com.afterpay.android.view + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Message +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import com.afterpay.android.CancellationStatus +import com.afterpay.android.R +import com.afterpay.android.internal.getCheckoutUrlExtra +import com.afterpay.android.internal.putCancellationStatusExtra +import com.afterpay.android.internal.putOrderTokenExtra +import com.afterpay.android.internal.setAfterpayUserAgentString + +internal class AfterpayCheckoutV3Activity : AppCompatActivity() { + + private companion object { + + val validCheckoutUrls = listOf( + "portal.afterpay.com", + "portal.sandbox.afterpay.com", + "portal.clearpay.co.uk", + "portal.sandbox.clearpay.co.uk" + ) + } + + private lateinit var webView: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_web_checkout) + + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + + webView = findViewById(R.id.afterpay_webView).apply { + setAfterpayUserAgentString() + settings.javaScriptEnabled = true + settings.setSupportMultipleWindows(true) + webViewClient = AfterpayWebViewClientV3( + receivedError = ::handleError, + completed = ::finish + ) + webChromeClient = AfterpayWebChromeClientV3(openExternalLink = ::open) + } + + loadCheckoutUrl() + } + + override fun onDestroy() { + // Prevent WebView from leaking memory when the Activity is destroyed. + // The leak appears when enabling JavaScript and is fixed by disabling it. + webView.apply { + stopLoading() + settings.javaScriptEnabled = false + } + + super.onDestroy() + } + + override fun onBackPressed() { + finish(CancellationStatus.USER_INITIATED) + } + + private fun loadCheckoutUrl() { + val checkoutUrl = intent.getCheckoutUrlExtra() + ?: return finish(CancellationStatus.NO_CHECKOUT_URL) + + if (validCheckoutUrls.contains(Uri.parse(checkoutUrl).host)) { + webView.loadUrl(checkoutUrl) + } else { + finish(CancellationStatus.INVALID_CHECKOUT_URL) + } + } + + private fun open(url: Uri) { + val intent = Intent(Intent.ACTION_VIEW, url) + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent) + } + } + + private fun handleError() { + // Clear default system error from the web view. + webView.loadUrl("about:blank") + + AlertDialog.Builder(this) + .setTitle(R.string.afterpay_load_error_title) + .setMessage(R.string.afterpay_load_error_message) + .setPositiveButton(R.string.afterpay_load_error_retry) { dialog, _ -> + loadCheckoutUrl() + dialog.dismiss() + } + .setNegativeButton(R.string.afterpay_load_error_cancel) { dialog, _ -> + dialog.cancel() + } + .setOnCancelListener { + finish(CancellationStatus.USER_INITIATED) + } + .show() + } + + private fun finish(status: CheckoutStatusV3) { + when (status) { + is CheckoutStatusV3.Success -> { + setResult(Activity.RESULT_OK, Intent().putOrderTokenExtra(status.orderToken)) + finish() + } + CheckoutStatusV3.Cancelled -> { + finish(CancellationStatus.USER_INITIATED) + } + } + } + + private fun finish(status: CancellationStatus) { + setResult(Activity.RESULT_CANCELED, Intent().putCancellationStatusExtra(status)) + finish() + } +} + +private class AfterpayWebViewClientV3( + private val receivedError: () -> Unit, + private val completed: (CheckoutStatusV3) -> Unit +) : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url ?: return false + val status = CheckoutStatusV3.fromUrl(url) + + return when { + status != null -> { + completed(status) + true + } + + else -> false + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + if (request?.isForMainFrame == true) { + receivedError() + } + } +} + +private class AfterpayWebChromeClientV3( + private val openExternalLink: (Uri) -> Unit +) : WebChromeClient() { + companion object { + const val URL_KEY = "url" + } + + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message? + ): Boolean { + val hrefMessage = view?.handler?.obtainMessage() + view?.requestFocusNodeHref(hrefMessage) + + val url = hrefMessage?.data?.getString(URL_KEY) + url?.let { openExternalLink(Uri.parse(it)) } + + return false + } +} + +private sealed class CheckoutStatusV3 { + data class Success(val orderToken: String) : CheckoutStatusV3() + object Cancelled : CheckoutStatusV3() + + companion object { + fun fromUrl(url: Uri): CheckoutStatusV3? = when (url.getQueryParameter("status")) { + "SUCCESS" -> url.getQueryParameter("orderToken")?.let(::Success) + "CANCELLED" -> Cancelled + else -> null + } + } +} From 3555944a65a240c5f649ba9138f5f8706b7f2ff5 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 20 Jul 2021 11:19:29 +1000 Subject: [PATCH 05/68] Add Example app code to display return data from V3 Checkout --- .../com/example/afterpay/MainActivity.kt | 21 ++++- .../afterpay/checkout/CheckoutFragment.kt | 27 ++++++ .../afterpay/checkout/CheckoutViewModel.kt | 9 +- .../afterpay/detailsv3/DetailsFragment.kt | 88 +++++++++++++++++++ .../afterpay/detailsv3/DetailsViewModel.kt | 43 +++++++++ .../kotlin/com/example/afterpay/nav_graph.kt | 3 + .../src/main/res/layout/fragment_details.xml | 80 +++++++++++++++++ 7 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 example/src/main/kotlin/com/example/afterpay/detailsv3/DetailsFragment.kt create mode 100644 example/src/main/kotlin/com/example/afterpay/detailsv3/DetailsViewModel.kt create mode 100644 example/src/main/res/layout/fragment_details.xml diff --git a/example/src/main/kotlin/com/example/afterpay/MainActivity.kt b/example/src/main/kotlin/com/example/afterpay/MainActivity.kt index f8810973..179d1147 100644 --- a/example/src/main/kotlin/com/example/afterpay/MainActivity.kt +++ b/example/src/main/kotlin/com/example/afterpay/MainActivity.kt @@ -15,8 +15,10 @@ import com.afterpay.android.Afterpay import com.afterpay.android.AfterpayEnvironment import com.afterpay.android.model.AfterpayRegion import com.afterpay.android.model.CheckoutV3Configuration +import com.afterpay.android.model.CheckoutV3Data import com.example.afterpay.checkout.CheckoutFragment import com.example.afterpay.data.AfterpayRepository +import com.example.afterpay.detailsv3.DetailsFragment import com.example.afterpay.receipt.ReceiptFragment import com.example.afterpay.shopping.ShoppingFragment import com.google.android.material.snackbar.Snackbar @@ -24,7 +26,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal -import java.util.Locale class MainActivity : AppCompatActivity() { private val afterpayRepository by lazy { @@ -56,6 +57,9 @@ class MainActivity : AppCompatActivity() { action(nav_graph.action.to_receipt) { destinationId = nav_graph.dest.receipt } + action(nav_graph.action.to_details_v3) { + destinationId = nav_graph.dest.details_v3 + } } fragment(nav_graph.dest.receipt) { label = getString(R.string.title_receipt) @@ -72,6 +76,21 @@ class MainActivity : AppCompatActivity() { } } } + fragment(nav_graph.dest.details_v3) { + label = "Single Use Card" + argument(nav_graph.args.result_data_v3) { + type = NavType.ParcelableType(CheckoutV3Data::class.java) + } + action(nav_graph.action.back_to_shopping) { + destinationId = nav_graph.dest.shopping + navOptions { + popUpTo(nav_graph.dest.shopping) { + inclusive = true + } + launchSingleTop = true + } + } + } } } diff --git a/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutFragment.kt b/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutFragment.kt index 4e11d40e..759a9b39 100644 --- a/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutFragment.kt +++ b/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutFragment.kt @@ -15,6 +15,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.afterpay.android.Afterpay +import com.afterpay.android.model.OrderTotal import com.afterpay.android.view.AfterpayPaymentButton import com.example.afterpay.R import com.example.afterpay.checkout.CheckoutViewModel.Command @@ -28,6 +29,7 @@ import java.math.BigDecimal class CheckoutFragment : Fragment() { private companion object { const val CHECKOUT_WITH_AFTERPAY = 1234 + const val CHECKOUT_WITH_AFTERPAY_V3 = 12345 } private val viewModel by viewModels { @@ -113,6 +115,19 @@ class CheckoutFragment : Fragment() { val intent = Afterpay.createCheckoutV2Intent(requireContext(), command.options) startActivityForResult(intent, CHECKOUT_WITH_AFTERPAY) } + is Command.ShowAfterpayCheckoutV3 -> { + val intent = Afterpay.createCheckoutV3Intent( + requireContext(), + consumer = command.consumer, + orderTotal = OrderTotal( + total = command.total, + shipping = BigDecimal.ZERO, + tax = BigDecimal.ZERO + ), + buyNow = command.buyNow + ) + startActivityForResult(intent, CHECKOUT_WITH_AFTERPAY_V3) + } is Command.ProvideCheckoutTokenResult -> checkoutHandler.provideTokenResult(command.tokenResult) is Command.ProvideShippingOptionsResult -> @@ -138,6 +153,18 @@ class CheckoutFragment : Fragment() { bundleOf(nav_graph.args.checkout_token to token) ) } + CHECKOUT_WITH_AFTERPAY_V3 to AppCompatActivity.RESULT_OK -> { + val intent = checkNotNull(data) { + "Intent should always be populated by the SDK" + } + val resultData = checkNotNull(Afterpay.parseCheckoutSuccessResponseV3(intent)) { + "Result data is always associated with a successful V3 Afterpay transaction" + } + findNavController().navigate( + nav_graph.action.to_details_v3, + bundleOf(nav_graph.args.result_data_v3 to resultData) + ) + } CHECKOUT_WITH_AFTERPAY to AppCompatActivity.RESULT_CANCELED -> { val intent = requireNotNull(data) { "Intent should always be populated by the SDK" diff --git a/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutViewModel.kt b/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutViewModel.kt index 11c5e4c0..27a3d666 100644 --- a/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutViewModel.kt +++ b/example/src/main/kotlin/com/example/afterpay/checkout/CheckoutViewModel.kt @@ -6,6 +6,8 @@ import androidx.core.content.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afterpay.android.AfterpayCheckoutV2Options +import com.afterpay.android.model.CheckoutV3Consumer +import com.afterpay.android.model.Consumer import com.afterpay.android.model.Money import com.afterpay.android.model.ShippingAddress import com.afterpay.android.model.ShippingOption @@ -52,6 +54,7 @@ class CheckoutViewModel( sealed class Command { data class ShowAfterpayCheckout(val options: AfterpayCheckoutV2Options) : Command() + data class ShowAfterpayCheckoutV3(val consumer: CheckoutV3Consumer, val total: BigDecimal, val buyNow: Boolean) : Command() data class ProvideCheckoutTokenResult(val tokenResult: Result) : Command() data class ProvideShippingOptionsResult(val shippingOptionsResult: ShippingOptionsResult) : Command() @@ -86,7 +89,7 @@ class CheckoutViewModel( } fun showAfterpayCheckout() { - val (email, _, isExpress, isBuyNow, isPickup, isShippingOptionsRequired) = state.value + val (email, total, isExpress, isBuyNow, isPickup, isShippingOptionsRequired) = state.value preferences.edit { putEmail(email) @@ -96,8 +99,8 @@ class CheckoutViewModel( putShippingOptionsRequired(isShippingOptionsRequired) } - val options = AfterpayCheckoutV2Options(isPickup, isBuyNow, isShippingOptionsRequired) - commandChannel.trySend(Command.ShowAfterpayCheckout(options)) + val consumer = Consumer(email = email) + commandChannel.trySend(Command.ShowAfterpayCheckoutV3(consumer, total, isBuyNow)) } fun loadCheckoutToken() { diff --git a/example/src/main/kotlin/com/example/afterpay/detailsv3/DetailsFragment.kt b/example/src/main/kotlin/com/example/afterpay/detailsv3/DetailsFragment.kt new file mode 100644 index 00000000..4be23238 --- /dev/null +++ b/example/src/main/kotlin/com/example/afterpay/detailsv3/DetailsFragment.kt @@ -0,0 +1,88 @@ +package com.example.afterpay.detailsv3 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.afterpay.android.model.CheckoutV3Data +import com.afterpay.android.model.VirtualCard +import com.example.afterpay.R +import com.example.afterpay.nav_graph +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class DetailsFragment : Fragment() { + + private val resultData: CheckoutV3Data + get() = requireNotNull(arguments?.getParcelable(nav_graph.args.result_data_v3)) + + private val viewModel by viewModels { DetailsViewModel.factory(resultData) } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_details, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().onBackPressedDispatcher.addCallback(this) { + findNavController().navigate(nav_graph.action.back_to_shopping) + } + + val cardDetails = resultData.cardDetails + + val cardNumberOrToken = view.findViewById(R.id.textView1) + val cvc = view.findViewById(R.id.textView2) + val cardExpiry = view.findViewById(R.id.textView3) + + when (cardDetails) { + is VirtualCard.Card -> { + cardNumberOrToken.text = "Card number: ${cardDetails.cardNumber}" + cvc.text = "CVC: ${cardDetails.cvc}" + cardExpiry.text = "Expiration: ${cardDetails.expiryMonth}/${cardDetails.expiryYear}" + } + is VirtualCard.TokenizedCard -> { + cardNumberOrToken.text = "Card token: ${cardDetails.cardToken}" + cvc.text = "CVC: ${cardDetails.cvc}" + cardExpiry.text = "Expiration: ${cardDetails.expiryMonth}/${cardDetails.expiryYear}" + } + else -> { + cardNumberOrToken.text = "Card token/number: Unexpected value" + cvc.text = "CVC: Unavailable" + cardExpiry.text = "Expiration: Unavailable" + } + } + + val expiration = view.findViewById(R.id.textView4) + expiration.text = "Virtual card expiry: ${resultData.cardValidUntil ?: "Unknown"}" + + val merchantReference = view.findViewById(R.id.textView5) + merchantReference.text = "Merchant reference: " + + val updateMerchantReferenceButton = view.findViewById