package com.stripe.wrap.pay.utils; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.Size; import android.text.TextUtils; import android.util.Log; import com.google.android.gms.wallet.Cart; import com.google.android.gms.wallet.LineItem; import com.stripe.wrap.pay.AndroidPayConfiguration; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Currency; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.UUID; import static com.stripe.wrap.pay.utils.PaymentUtils.getPriceString; /** * A wrapper for {@link Cart.Builder} that aids in the generation of new {@link LineItem} * objects. */ public class CartManager { static final String REGULAR_ID = "REG"; static final String SHIPPING_ID = "SHIP"; static final String TAG = CartManager.class.getName(); private final Currency mCurrency; @NonNull private LinkedHashMap<String, LineItem> mLineItemsRegular = new LinkedHashMap<>(); @NonNull private LinkedHashMap<String, LineItem> mLineItemsShipping = new LinkedHashMap<>(); @Nullable private LineItem mLineItemTax; @Nullable private Long mCachedTotalPrice; /** * Create a new CartManager. Currency will be set to {@link AndroidPayConfiguration#mCurrency}. * Note that this will throw a {@link RuntimeException} if a currency has not been set on * the {@link AndroidPayConfiguration}. */ public CartManager() { mCurrency = AndroidPayConfiguration.getInstance().getCurrency(); } /** * Create a new CartManager with the specified currency code. Note that if this differs from * the value contained in the singleton {@link AndroidPayConfiguration}, the configuration * currency will change. * * @param currencyCode a currency code used for this cart, and the rest of the application */ public CartManager(String currencyCode) { mCurrency = Currency.getInstance(currencyCode.toUpperCase()); synchronizeCartCurrencyWithConfiguration(mCurrency); } /** * Create a {@link CartManager} from an old {@link Cart} instance. Can be used to * alter old {@link Cart} instances that need to update shipping or tax information. * By default, {@link LineItem LineItems} in this cart are only copied over if their * role is {@link LineItem.Role#REGULAR}. * * @param oldCart a {@link Cart} from which to copy the regular {@link LineItem LineItems} and * currency code. */ public CartManager(@NonNull Cart oldCart) { this(oldCart, false, false); } /** * Create a {@link CartManager} from an old {@link Cart} instance. Can be used to * alter old {@link Cart} instances that need to update shipping or tax information. * By default, {@link LineItem LineItems} in this cart are only copied over if their * role is {@link LineItem.Role#REGULAR}. * * @param oldCart a {@link Cart} from which to copy the currency code and line items * @param shouldKeepShipping {@code true} if items with role {@link LineItem.Role#SHIPPING} * should be copied, {@code false} if not * @param shouldKeepTax {@code true} if items with role {@link LineItem.Role#TAX} should be * should be copied. Note: constructor does not check to see if the input * {@link Cart} is valid, so multiple tax items will overwrite each other, * and only the last one will be kept */ public CartManager(@NonNull Cart oldCart, boolean shouldKeepShipping, boolean shouldKeepTax) { mCurrency = Currency.getInstance(oldCart.getCurrencyCode()); synchronizeCartCurrencyWithConfiguration(mCurrency); for (LineItem item : oldCart.getLineItems()) { switch (item.getRole()) { case LineItem.Role.REGULAR: addLineItem(item); break; case LineItem.Role.SHIPPING: if (shouldKeepShipping) { addLineItem(item); } break; case LineItem.Role.TAX: if (shouldKeepTax) { setTaxLineItem(item); } break; default: // Unknown type. Treating as REGULAR. Will trigger log warning in additem. addLineItem(item); break; } } if (shouldKeepShipping && shouldKeepTax && !TextUtils.isEmpty(oldCart.getTotalPrice())) { Long oldTotal = PaymentUtils.getPriceLong(oldCart.getTotalPrice(), mCurrency); setTotalPrice(oldTotal); } } /** * Adds a {@link LineItem.Role#REGULAR} item to the cart with a description * and total price value. Currency matches the currency of the {@link CartManager}. * * @param description a line item description * @param totalPrice the total price of the line item, in the smallest denomination * @return a {@link String} UUID that can be used to access the item in this {@link CartManager} * */ @Nullable public String addLineItem(@NonNull @Size(min = 1) String description, long totalPrice) { return addLineItem(new LineItemBuilder(mCurrency.getCurrencyCode()) .setDescription(description) .setTotalPrice(totalPrice) .build()); } /** * Adds a line item with quantity and unit price. Total price is calculated and added to the * line item. * * @param description a line item description * @param quantity the quantity of the line item * @param unitPrice the unit price of the line item * @return a {@link String} UUID that can be used to access the item in this {@link CartManager} */ @Nullable public String addLineItem(@NonNull @Size(min = 1) String description, double quantity, long unitPrice) { BigDecimal roundedQuantity = new BigDecimal(quantity).setScale(1, BigDecimal.ROUND_DOWN); long totalPrice = roundedQuantity.multiply(new BigDecimal(unitPrice)).longValue(); return addLineItem(new LineItemBuilder(mCurrency.getCurrencyCode()) .setDescription(description) .setTotalPrice(totalPrice) .setUnitPrice(unitPrice) .setQuantity(roundedQuantity) .setRole(LineItem.Role.REGULAR) .build()); } /** * Adds a {@link LineItem.Role#SHIPPING} item to the cart with a description * and total price value. Currency matches the currency of the {@link CartManager}. * * @param description a line item description * @param totalPrice the total price of the line item, in the smallest denomination * @return a {@link String} UUID that can be used to access the item in this {@link CartManager} */ @Nullable public String addShippingLineItem(@NonNull @Size(min = 1) String description, long totalPrice) { return addLineItem(new LineItemBuilder(mCurrency.getCurrencyCode()) .setDescription(description) .setTotalPrice(totalPrice) .setRole(LineItem.Role.SHIPPING) .build()); } /** * Adds a shipping line item with quantity and unit price. * Total price is calculated and added to the line item. * * @param description a line item description * @param quantity the quantity of the line item * @param unitPrice the unit price of the line item * @return a {@link String} UUID that can be used to access the item in this {@link CartManager} */ @Nullable public String addShippingLineItem(@NonNull @Size(min = 1) String description, double quantity, long unitPrice) { BigDecimal roundedQuantity = new BigDecimal(quantity).setScale(1, BigDecimal.ROUND_DOWN); long totalPrice = roundedQuantity.multiply(new BigDecimal(unitPrice)).longValue(); return addLineItem(new LineItemBuilder(mCurrency.getCurrencyCode()) .setDescription(description) .setTotalPrice(totalPrice) .setUnitPrice(unitPrice) .setQuantity(roundedQuantity) .setRole(LineItem.Role.SHIPPING) .build()); } /** * Calculate the total price of all {@link LineItem.Role#REGULAR} * line items, if possible. * * @return the total price of regular items, or {@code null} if that cannot * be calculated because of mixed currencies. */ @Nullable public Long calculateRegularItemTotal() { return PaymentUtils.getTotalPrice(mLineItemsRegular.values(), mCurrency); } /** * Calculate the total price of all {@link LineItem.Role#SHIPPING} * line items, if possible. * * @return the total price of shipping items, or {@code null} if that cannot * be calculated because of mixed currencies. */ @Nullable public Long calculateShippingItemTotal() { return PaymentUtils.getTotalPrice(mLineItemsShipping.values(), mCurrency); } /** * Gets the price of the {@link LineItem.Role#TAX} item, if it exists and * has the same currency as the cart. * * @return the value of the tax item, zero if it doesn't exist, or {@code null} if * the value is in the wrong currency or is not given */ public Long calculateTax() { if (mLineItemTax == null) { return 0L; } if (!mCurrency.getCurrencyCode().equals(mLineItemTax.getCurrencyCode())) { return null; } return PaymentUtils.getPriceLong(mLineItemTax.getTotalPrice(), mCurrency); } /** * Adds a {@link LineItem.Role#TAX} item to the cart with a description * and total price value. Currency matches the currency of the {@link CartManager}. * * @param description a line item description * @param totalPrice the total price of the line item, in the smallest denomination */ public void setTaxLineItem(@NonNull @Size(min = 1) String description, long totalPrice) { LineItem taxLineItem = new LineItemBuilder(mCurrency.getCurrencyCode()) .setDescription(description) .setTotalPrice(totalPrice) .setRole(LineItem.Role.TAX) .build(); addLineItem(taxLineItem); } /** * Setter for the total price. Can be used if you want the price of the cart to differ * from the sum of the prices of the items within the cart. * * @param totalPrice a number representing the price, in the lowest possible denomination * of the cart's currency, or {@code null} to clear the value */ public void setTotalPrice(@Nullable Long totalPrice) { mCachedTotalPrice = totalPrice; } /** * Sets the tax line item in this cart manager. Can be used to clear the tax item by using * {@code null} input. If the input {@link LineItem} has a role other than * {@link LineItem.Role#TAX}, the input is ignored. * * @param item a {@link LineItem} with role {@link LineItem.Role#TAX}, or {@code null} */ public void setTaxLineItem(@Nullable LineItem item) { if (item == null) { if (mLineItemTax != null && !TextUtils.isEmpty(mLineItemTax.getTotalPrice())) { setTotalPrice(null); } mLineItemTax = item; } else { addLineItem(item); } } /** * Remove an item from the {@link CartManager}. Clears any currently set manual total price if * an item is removed. * * @param itemId the UUID associated with the cart item to be removed * @return the {@link LineItem} removed, or {@code null} if no item was found */ @Nullable public LineItem removeLineItem(@NonNull @Size(min = 1) String itemId) { LineItem removed = mLineItemsRegular.remove(itemId); if (removed == null) { removed = mLineItemsShipping.remove(itemId); } if (removed != null && !TextUtils.isEmpty(removed.getTotalPrice())) { setTotalPrice(null); } return removed; } /** * Add a {@link LineItem} to the cart. Removes any currently set or calculated total price value * if the item being added has a nonempty total price. * * @param item the {@link LineItem} to be added * @return a {@link String} UUID that can be used to access the item in this {@link CartManager} */ @Nullable public String addLineItem(@NonNull LineItem item) { String itemId = null; if (!TextUtils.isEmpty(item.getTotalPrice())) { setTotalPrice(null); } switch (item.getRole()) { case LineItem.Role.REGULAR: itemId = generateUuidForRole(LineItem.Role.REGULAR); mLineItemsRegular.put(itemId, item); break; case LineItem.Role.SHIPPING: itemId = generateUuidForRole(LineItem.Role.SHIPPING); mLineItemsShipping.put(itemId, item); break; case LineItem.Role.TAX: if (mLineItemTax != null) { Log.w(TAG, String.format(Locale.ENGLISH, "Adding a tax line item, but a tax line item " + "already exists. Old tax of %s is being overwritten " + "to maintain a valid cart.", mLineItemTax.getTotalPrice())); } // We're swapping out the tax item, so we have to remove the old one. mLineItemTax = item; break; default: Log.w(TAG, String.format(Locale.ENGLISH, "Line item with unknown role added to cart. Treated as regular. " + "Unknown role is of code %d", item.getRole())); itemId = generateUuidForRole(LineItem.Role.REGULAR); mLineItemsRegular.put(itemId, item); break; } return itemId; } /** * Build the {@link Cart}. Uses the manually set price if one is set, or attempts to calculate * the price if no manual price is set. Calculation will fail if line item currencies do not * match cart currency. * * @return a {@link Cart} * @throws CartContentException if there are invalid line items or invalid cart parameters. The * exception will contain a list of CartError objects specifying the problems. */ @NonNull public Cart buildCart() throws CartContentException { List<LineItem> totalLineItems = new ArrayList<>(); totalLineItems.addAll(mLineItemsRegular.values()); totalLineItems.addAll(mLineItemsShipping.values()); if (mLineItemTax != null) { totalLineItems.add(mLineItemTax); } List<CartError> errors = PaymentUtils.validateLineItemList( totalLineItems, mCurrency.getCurrencyCode()); Long totalPrice = getTotalPrice(); String totalPriceString = totalPrice == null ? null : getPriceString(totalPrice, mCurrency); if (!TextUtils.isEmpty(totalPriceString)) { // If a manual value has been set for the total price string, then we don't need // to calculate this on our own, and mixed currency line items are not an error state. errors = PaymentUtils.removeErrorType(errors, CartError.LINE_ITEM_CURRENCY); } if (errors.isEmpty()) { return Cart.newBuilder() .setCurrencyCode(mCurrency.getCurrencyCode()) .setLineItems(totalLineItems) .setTotalPrice(totalPriceString) .build(); } else { throw new CartContentException(errors); } } /** * Get the current total price for the cart. If the value has been manually set or previously * calculated, the cached value is returned. If no such value has been set (or the value * has been invalidated by the addition or removal of items), then a new value is calculated, * cached, and returned. * * @return the total price of the items in this CartManager, or {@code null} if that value * is neither set nor able to be calculated */ @Nullable public Long getTotalPrice() { if (mCachedTotalPrice != null) { return mCachedTotalPrice; } // Regular, Shipping, and Tax Long[] sectionPrices = new Long[3]; sectionPrices[0] = calculateRegularItemTotal(); sectionPrices[1] = calculateShippingItemTotal(); sectionPrices[2] = calculateTax(); Long totalPrice = null; for (int i = 0 ; i < sectionPrices.length; i++) { if (sectionPrices[i] == null) { return null; } if (totalPrice == null) { totalPrice = sectionPrices[i]; } else { totalPrice += sectionPrices[i]; } } // There is no need to repeat this calculation until items are added or removed. mCachedTotalPrice = totalPrice; return totalPrice; } @NonNull public Currency getCurrency() { return mCurrency; } @NonNull public String getCurrencyCode() { return mCurrency.getCurrencyCode(); } @NonNull public LinkedHashMap<String, LineItem> getLineItemsRegular() { return mLineItemsRegular; } @NonNull public LinkedHashMap<String, LineItem> getLineItemsShipping() { return mLineItemsShipping; } @Nullable public LineItem getLineItemTax() { return mLineItemTax; } @NonNull static String generateUuidForRole(int role) { String baseId = UUID.randomUUID().toString(); String base = null; if (role == LineItem.Role.REGULAR) { base = REGULAR_ID; } else if (role == LineItem.Role.SHIPPING) { base = SHIPPING_ID; } StringBuilder builder = new StringBuilder(); if (base != null) { return builder.append(base) .append('-') .append(baseId.substring(base.length())) .toString(); } return baseId; } private void synchronizeCartCurrencyWithConfiguration(@NonNull Currency currency) { if (currency.getCurrencyCode().equals( AndroidPayConfiguration.getInstance().getCurrencyCode())) { return; } String updatedCurrencyCode = currency.getCurrencyCode(); String oldCurrencyCode = AndroidPayConfiguration.getInstance().getCurrencyCode(); Log.w(TAG, String.format(Locale.ENGLISH, "Cart created with currency code %s, which differs from current " + "AndroidPayConfiguration currency, %s. Changing configuration " + "to %s", updatedCurrencyCode, oldCurrencyCode, updatedCurrencyCode)); AndroidPayConfiguration.getInstance().setCurrency(currency); } }