package com.stripe.wrap.pay.utils; import android.text.TextUtils; import android.util.Log; import com.google.android.gms.wallet.LineItem; import com.stripe.wrap.pay.AndroidPayConfiguration; import java.math.BigDecimal; import java.math.MathContext; import java.util.Currency; import java.util.HashSet; import java.util.Locale; import java.util.Set; /** * A wrapper for {@link LineItem.Builder} that allows you to use price as a {@link Long} and * to easily set default currency values. */ public class LineItemBuilder { public static final Set<Integer> VALID_ROLES = new HashSet<Integer>(){{ add(LineItem.Role.TAX); add(LineItem.Role.REGULAR); add(LineItem.Role.SHIPPING); }}; static final String TAG = "Stripe:LineItemBuilder"; private static final double CONSISTENCY_THRESHOLD = 0.01; private static final int CONSISTENCY_SCALE = 3; private Currency mCurrency; private Long mUnitPrice; private Long mTotalPrice; private BigDecimal mQuantity; private String mDescription; private int mRole; /** * Construct a {@link LineItem} using {@link LineItem.Role#REGULAR} and the currency code * from {@link AndroidPayConfiguration#getCurrencyCode()}. */ LineItemBuilder() { this(AndroidPayConfiguration.getInstance().getCurrencyCode()); } /** * Construct a {@link LineItem} using a customized currency code. Role is initially set to * {@link LineItem.Role#REGULAR}. * * @param currencyCode */ LineItemBuilder(String currencyCode) { setCurrencyCode(currencyCode); mRole = LineItem.Role.REGULAR; } /** * Sets the ISO 4217 currency code of the line item. If the input currency is invalid, * currency is set to the default for the phone's locale. * * @param currencyCode the currency code to set * @return {@code this}, for chaining purposes */ public LineItemBuilder setCurrencyCode(String currencyCode) { mCurrency = Currency.getInstance(currencyCode.toUpperCase()); return this; } public LineItemBuilder setUnitPrice(long unitPrice) { mUnitPrice = unitPrice; return this; } public LineItemBuilder setTotalPrice(long totalPrice) { mTotalPrice = totalPrice; return this; } /** * Sets the quantity for this line item. Note: the quantity may have at most one number * after the decimal place. Further precision will be rounded away. * * @param quantity the quantity of this line item * @return {@code this}, for chaining purposes */ public LineItemBuilder setQuantity(BigDecimal quantity) { if (quantity.scale() > 1) { mQuantity = quantity.setScale(1, BigDecimal.ROUND_HALF_EVEN); Log.w(TAG, String.format( Locale.ENGLISH, "Tried to create quantity %.2f, but Android Pay quantity" + " may only have one digit after decimal. Value was rounded to %s", quantity, mQuantity.toString())); } else { mQuantity = quantity; } return this; } /** * Sets the quantity for this line item. Note: the quantity may have at most one number * after the decimal place. Further precision will be rounded away. * * @param quantity the quantity of this line item * @return {@code this}, for chaining purposes */ public LineItemBuilder setQuantity(double quantity) { BigDecimal fullQuantity = BigDecimal.valueOf(quantity); mQuantity = BigDecimal.valueOf(quantity).setScale(1, BigDecimal.ROUND_HALF_EVEN); if (fullQuantity.scale() > 1) { Log.w(TAG, String.format( Locale.ENGLISH, "Tried to create quantity %.2f, but Android Pay quantity" + " may only have one digit after decimal. Value was rounded to %s", quantity, mQuantity.toString())); } return this; } public LineItemBuilder setDescription(String description) { mDescription = description; return this; } /** * Sets the {@link LineItem.Role} of this line item, if the input is a member of * {@link #VALID_ROLES}. * * @param role the {@link LineItem.Role} of this item * @return {@code this}, for chaining purposes */ public LineItemBuilder setRole(int role) { if (VALID_ROLES.contains(role)) { mRole = role; } return this; } public LineItem build() { LineItem.Builder androidPayBuilder = LineItem.newBuilder(); androidPayBuilder.setCurrencyCode(mCurrency.getCurrencyCode()).setRole(mRole); if (mTotalPrice != null) { androidPayBuilder.setTotalPrice(PaymentUtils.getPriceString(mTotalPrice, mCurrency)); } if (mUnitPrice != null) { androidPayBuilder.setUnitPrice(PaymentUtils.getPriceString(mUnitPrice, mCurrency)); } if (mQuantity != null) { if (isWholeNumber(mQuantity)) { androidPayBuilder.setQuantity(mQuantity.toBigInteger().toString()); } else { androidPayBuilder.setQuantity(mQuantity.toString()); } } if (mTotalPrice == null && mQuantity != null && mUnitPrice != null) { mTotalPrice = mQuantity.multiply( BigDecimal.valueOf(mUnitPrice), MathContext.DECIMAL64).longValue(); androidPayBuilder.setTotalPrice(PaymentUtils.getPriceString(mTotalPrice, mCurrency)); } else { if (!isPriceBreakdownConsistent(mUnitPrice, mQuantity, mTotalPrice)) { Log.w(TAG, String.format(Locale.ENGLISH, "Price breakdown of %d * %.1f = %d is off by more than 1 percent", mUnitPrice, mQuantity.floatValue(), mTotalPrice)); } } if (mRole != LineItem.Role.REGULAR) { androidPayBuilder.setRole(mRole); } if (!TextUtils.isEmpty(mDescription)) { androidPayBuilder.setDescription(mDescription); } return androidPayBuilder.build(); } static boolean isWholeNumber(BigDecimal number) { return number.remainder(BigDecimal.ONE).compareTo(BigDecimal.ZERO) == 0; } /** * Checks to see if the unit * quantity expected price is within 1% of the * listed totalPrice. Returns {@code true} if any value is null. * * @param unitPrice the listed price per unit of the line item * @param quantity the listed quantity of the line item * @param totalPrice the listed total price of the line item * @return {@code true} if the quantity or unit price is zero, or if any item is null. * Otherwise, {@code true} if and only if totalPrice / (unitPrice * quantity) is between * 0.99 and 1.01. */ static boolean isPriceBreakdownConsistent( Long unitPrice, BigDecimal quantity, Long totalPrice) { if (unitPrice == null || quantity == null || totalPrice == null) { return true; } BigDecimal expectedPrice = quantity.multiply(BigDecimal.valueOf(unitPrice)); BigDecimal actualPrice = BigDecimal.valueOf(totalPrice); if (expectedPrice.compareTo(BigDecimal.ZERO) == 0) { return totalPrice == 0; } double ratio = actualPrice.divide( expectedPrice, CONSISTENCY_SCALE, BigDecimal.ROUND_HALF_EVEN).doubleValue(); return Math.abs(ratio - 1.0) < CONSISTENCY_THRESHOLD; } }