package com.stripe.wrap.pay.utils; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Currency; import java.util.List; import java.util.Locale; import java.util.regex.Pattern; import com.google.android.gms.wallet.Cart; import com.google.android.gms.wallet.IsReadyToPayRequest; import com.google.android.gms.wallet.LineItem; import com.google.android.gms.wallet.WalletConstants; import com.stripe.wrap.pay.AndroidPayConfiguration; /** * Utility class for easily generating Android Pay items. */ public class PaymentUtils { static final String TAG = "Stripe:PaymentUtils"; static final String CURRENCY_REGEX = "\"^-?[0-9]+(\\.[0-9][0-9])?\""; static final String QUANTITY_REGEX = "\"[0-9]+(\\.[0-9])?\""; @Nullable static Long getTotalPrice(@NonNull Collection<LineItem> lineItems, @NonNull Currency currency) { Long totalPrice = null; for (LineItem lineItem : lineItems) { if (!currency.getCurrencyCode().equals(lineItem.getCurrencyCode())) { return null; } Long itemPrice = getPriceLong(lineItem.getTotalPrice(), currency); if (itemPrice != null) { if (totalPrice == null) { totalPrice = itemPrice; } else { totalPrice += itemPrice; } } } // In this case, the values were simply all empty, not invalid. The total price is zero. if (totalPrice == null) { return 0L; } return totalPrice; } /** * Utility function to convert the already-valid price String obtained from a {@link LineItem} * into a {@link Long} value that can be used for calculations. * * @param price a price string that successfully passes * {@link #matchesCurrencyPatternOrEmpty(String)}. * If the input does not match this pattern * an {@link IllegalArgumentException} is thrown. * @param currency the {@link Currency} to expect for this price value * @return {@code null} if the input is empty, otherwise a {@link Long} value representing * the price in the lowest denomination of the input {@link Currency} */ @Nullable static Long getPriceLong(@Nullable String price, @NonNull Currency currency) { if (TextUtils.isEmpty(price)) { return null; } if (!matchesCurrencyPatternOrEmpty(price)) { throw new IllegalArgumentException(String.format(Locale.ENGLISH, "%s is not a valid price String for a LineItem", price)); } int fractionDigits = currency.getDefaultFractionDigits(); if (fractionDigits == 0) { return Long.parseLong(price); } // This is the case where the currency has a decimal, but our price does not. // For instance if the currency was USD and the price string was "2", we want to return // 200L, not 2L. if (!price.contains(".")) { long multiplier = (long) Math.pow(10, fractionDigits); long displayNumber = Long.parseLong(price); return displayNumber * multiplier; } String noDecimal = price.replace(".", ""); return Long.parseLong(noDecimal); } /** * Checks whether or not the input String matches the regex required for Android Pay price * descriptions. This string should not include a currency symbol or separators. * For instance, one thousand USD would be input as "1000.00". * * @param priceString a String that may get displayed to the user * @return {@code true} if this string can be used as a price in a {@link Cart} or * a {@link LineItem} */ public static boolean matchesCurrencyPatternOrEmpty(@Nullable String priceString) { if (TextUtils.isEmpty(priceString)) { return true; } Pattern pattern = Pattern.compile("^-?[0-9]+(\\.[0-9][0-9])?"); return pattern.matcher(priceString).matches(); } /** * Checks whether or not the input String matches the regex required for Android Pay quantity * descriptions. This string should not include a negative sign or separators. * It may include one number after the decimal. * * @param quantityString a String that may get displayed to the user * @return {@code true} if this string can be used as a price in a {@link Cart} or * a {@link LineItem} */ public static boolean matchesQuantityPatternOrEmpty(@Nullable String quantityString) { if (TextUtils.isEmpty(quantityString)) { return true; } Pattern pattern = Pattern.compile("[0-9]+(\\.[0-9])?"); return pattern.matcher(quantityString).matches(); } /** * Checks whether or not a list of {@link LineItem} objects is valid. A {@link Cart} may have * at most one item with a role of {@link LineItem.Role#TAX}. All items in a {@link Cart} must * have the same currency code, and it must match the input currency code * * @param lineItems a list of {@link LineItem} objects * @param currencyCode the currency code used to evaluate the list * @return {@code true} if the list could be put into a {@link Cart}, false otherwise */ @NonNull public static List<CartError> validateLineItemList( List<LineItem> lineItems, @NonNull String currencyCode) { List<CartError> cartErrors = new ArrayList<>(); if (lineItems == null) { return cartErrors; } try { Currency.getInstance(currencyCode); } catch (IllegalArgumentException illegalArgumentException) { cartErrors.add(new CartError(CartError.CART_CURRENCY, String.format(Locale.ENGLISH, "Cart does not have a valid currency code. " + "%s was used, but not recognized.", TextUtils.isEmpty(currencyCode) ? "[empty]": currencyCode))); } boolean hasTax = false; for (LineItem item : lineItems) { if (!currencyCode.equals(item.getCurrencyCode())) { cartErrors.add(new CartError( CartError.LINE_ITEM_CURRENCY, String.format(Locale.ENGLISH, "Line item currency of %s does not match cart currency of %s.", TextUtils.isEmpty(item.getCurrencyCode()) ? "[empty]": item.getCurrencyCode(), currencyCode), item)); } if (LineItem.Role.TAX == item.getRole()) { if (hasTax) { cartErrors.add(new CartError( CartError.DUPLICATE_TAX, "A cart may only have one item with a role of " + "LineItem.Role.TAX, but more than one was found.", item)); } else { hasTax = true; } } CartError lineItemError = searchLineItemForErrors(item); if (lineItemError != null) { cartErrors.add(lineItemError); } } return cartErrors; } /** * Checks whether or not the fields of the input {@link LineItem} are valid, according to the * Android Pay specifications. * * @param lineItem a {@link LineItem} to check * @return {@code true} if this item is valid to be added to a {@link Cart} */ public static CartError searchLineItemForErrors(LineItem lineItem) { if (lineItem == null) { return null; } if (!matchesCurrencyPatternOrEmpty(lineItem.getUnitPrice())) { return new CartError(CartError.LINE_ITEM_PRICE, String.format(Locale.ENGLISH, "Invalid price string: %s does not match required pattern of %s", lineItem.getUnitPrice(), CURRENCY_REGEX), lineItem); } if (!matchesQuantityPatternOrEmpty(lineItem.getQuantity())) { return new CartError(CartError.LINE_ITEM_QUANTITY, String.format(Locale.ENGLISH, "Invalid quantity string: %s does not match required pattern of %s", lineItem.getQuantity(), QUANTITY_REGEX), lineItem); } if (!matchesCurrencyPatternOrEmpty(lineItem.getTotalPrice())) { return new CartError(CartError.LINE_ITEM_PRICE, String.format(Locale.ENGLISH, "Invalid price string: %s does not match required pattern of %s", lineItem.getTotalPrice(), CURRENCY_REGEX), lineItem); } return null; } /** * Converts an integer price in the lowest currency denomination to a Google string value. * The currency is assumed to be from the current {@link AndroidPayConfiguration} singleton. * * @param price the price in the lowest available currency denomination * @return a String that can be used as an Android Pay price string */ @NonNull public static String getPriceString(long price) { return getPriceString(price, AndroidPayConfiguration.getInstance().getCurrency()); } /** * Filter a list of {@link CartError} objects to remove all of a given type. * * @param errors the original list of {@link CartError CartErrors} * @param errorType the {@link CartError.CartErrorType} to remove from the list * @return the original list, minus any of the errors that were of the filtered type */ @NonNull public static List<CartError> removeErrorType( @NonNull List<CartError> errors, @NonNull @CartError.CartErrorType String errorType) { List<CartError> filteredErrors = new ArrayList<>(); for (CartError error : errors) { if (errorType.equals(error.getErrorType())) { continue; } filteredErrors.add(error); } return filteredErrors; } /** * Converts an integer price in the lowest currency denomination to a Google string value. * For instance (100, USD) -> "1.00", but (100, JPY) -> "100" * @param price the price in the lowest available currency denomination * @param currency the {@link Currency} used to determine how many digits after the decimal * @return a String that can be used as an Android Pay price string */ @NonNull public static String getPriceString(Long price, @NonNull Currency currency) { if (price == null) { return ""; } int fractionDigits = currency.getDefaultFractionDigits(); int totalLength = String.valueOf(price).length(); StringBuilder builder = new StringBuilder(); if (fractionDigits == 0) { for (int i = 0; i < totalLength; i++) { builder.append('#'); } DecimalFormat noDecimalCurrencyFormat = new DecimalFormat(builder.toString()); noDecimalCurrencyFormat.setCurrency(currency); return noDecimalCurrencyFormat.format(price); } int beforeDecimal = totalLength - fractionDigits; for (int i = 0; i < beforeDecimal; i++) { builder.append('#'); } // So we display "0.55" instead of ".55" if (totalLength <= fractionDigits) { builder.append('0'); } builder.append('.'); for (int i = 0; i < fractionDigits; i++) { builder.append('0'); } double modBreak = Math.pow(10, fractionDigits); double decimalPrice = price / modBreak; DecimalFormat decimalFormat = new DecimalFormat(builder.toString()); decimalFormat.setCurrency(currency); return decimalFormat.format(decimalPrice); } /** * Get an {@link IsReadyToPayRequest} that contains all of Stripe's accepted cards. * * @return an {@link IsReadyToPayRequest} that explicitly allows AmEx, Discover, JCB, VISA, * and Mastercard */ public static IsReadyToPayRequest getStripeIsReadyToPayRequest() { return IsReadyToPayRequest.newBuilder() .addAllowedCardNetwork(WalletConstants.CardNetwork.AMEX) .addAllowedCardNetwork(WalletConstants.CardNetwork.DISCOVER) .addAllowedCardNetwork(WalletConstants.CardNetwork.JCB) .addAllowedCardNetwork(WalletConstants.CardNetwork.MASTERCARD) .addAllowedCardNetwork(WalletConstants.CardNetwork.VISA) .build(); } }