/*
* The MIT License (MIT)
*
* Copyright (c) 2013 Brien L. Wheeler (brienwheeler@yahoo.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.brienwheeler.svc.authorize_net.impl;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import com.brienwheeler.lib.monitor.work.MonitoredWork;
import com.brienwheeler.lib.svc.GracefulShutdown;
import net.authorize.Environment;
import net.authorize.Merchant;
import net.authorize.ResponseCode;
import net.authorize.ResponseField;
import net.authorize.ResponseReasonCode;
import net.authorize.cim.Result;
import net.authorize.cim.Transaction;
import net.authorize.cim.TransactionType;
import net.authorize.cim.ValidationModeType;
import net.authorize.data.Order;
import net.authorize.data.cim.CustomerProfile;
import net.authorize.data.cim.DirectResponse;
import net.authorize.data.cim.PaymentProfile;
import net.authorize.data.cim.PaymentTransaction;
import net.authorize.data.xml.Payment;
import net.authorize.util.BasicXmlDocument;
import net.authorize.xml.Message;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.entity.EntityBuilder;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.brienwheeler.lib.db.DbValidationUtils;
import com.brienwheeler.lib.db.TransactionWrapper;
import com.brienwheeler.lib.db.domain.DbId;
import com.brienwheeler.lib.util.OperationDisallowedException;
import com.brienwheeler.svc.authorize_net.AmountTooLargeException;
import com.brienwheeler.svc.authorize_net.AuthorizeNetException;
import com.brienwheeler.svc.authorize_net.CardExpiredException;
import com.brienwheeler.svc.authorize_net.ICIMClientService;
import com.brienwheeler.svc.authorize_net.InvalidCardAddressException;
import com.brienwheeler.svc.authorize_net.InvalidCardCodeException;
import com.brienwheeler.svc.authorize_net.InvalidCardNumberException;
import com.brienwheeler.svc.authorize_net.PaymentDeclinedException;
import com.brienwheeler.svc.authorize_net.PaymentMethod;
import com.brienwheeler.svc.users.IUserAttributeService;
import com.brienwheeler.svc.users.IUserService;
import com.brienwheeler.svc.users.domain.User;
public class CIMClientService extends AuthorizeNetClientBase implements ICIMClientService
{
private static final String PRODUCTION_URL = "https://api.authorize.net/xml/v1/request.api";
private static final String TEST_URL = "https://apitest.authorize.net/xml/v1/request.api";
private static final String ELEMENT_TOKEN_OPEN = "<token>";
private static final String ELEMENT_TOKEN_CLOSE = "</token>";
private static final Map<ResponseReasonCode,Class<? extends AuthorizeNetException>> exceptionMap =
new HashMap<ResponseReasonCode,Class<? extends AuthorizeNetException>>();
private IUserAttributeService userAttributeService;
private IUserService userService;
private TransactionWrapper transactionWrapper;
private Environment environment;
private Merchant merchant;
private String apiLoginID;
private String transactionKey;
private ValidationModeType validationMode = ValidationModeType.NONE;
static {
exceptionMap.put(ResponseReasonCode.RRC_2_27, InvalidCardAddressException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_37, InvalidCardNumberException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_44, InvalidCardCodeException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_65, InvalidCardCodeException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_127, InvalidCardAddressException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_315, InvalidCardNumberException.class);
exceptionMap.put(ResponseReasonCode.RRC_2_317, CardExpiredException.class);
exceptionMap.put(ResponseReasonCode.RRC_3_6, InvalidCardNumberException.class);
exceptionMap.put(ResponseReasonCode.RRC_3_8, CardExpiredException.class);
exceptionMap.put(ResponseReasonCode.RRC_3_49, AmountTooLargeException.class);
exceptionMap.put(ResponseReasonCode.RRC_3_78, InvalidCardCodeException.class);
}
@Override
protected void onStart() throws InterruptedException
{
super.onStart();
merchant = Merchant.createMerchant(environment, apiLoginID, transactionKey);
}
@Override
public boolean isProduction()
{
return ! merchant.isSandboxEnvironment();
}
@Override
@MonitoredWork
@GracefulShutdown
// the write logic inside this function is in its own new transaction, so the interceptor can
// treat this as a readOnly transaction
@Transactional(readOnly=true, propagation=Propagation.SUPPORTS)
public String createCustomerProfile(DbId<User> userId)
{
String existingCustomerProfileId = userAttributeService.getAttribute(userId, ATTR_PROFILE_ID);
if (existingCustomerProfileId != null)
return existingCustomerProfileId;
final User user = userService.findById(userId);
DbValidationUtils.assertPersisted(user);
CustomerProfile customerProfile = CustomerProfile.createCustomerProfile();
customerProfile.setMerchantCustomerId(Long.toString(userId.getId()));
Transaction transaction = createTransaction(TransactionType.CREATE_CUSTOMER_PROFILE);
transaction.setCustomerProfile(customerProfile);
Result<Transaction> result = executeTransaction("create profile", userId, transaction);
final String createdCustomerProfileId = result.getCustomerProfileId();
log.info("created Authorize.Net customer profile " + createdCustomerProfileId + " for " + user);
// we want to commit our own transaction to prevent the record from being created at Authorize.Net
// and then an exception in a calling function that might have a transaction open preventing
// the save of the UserAttribute recording the customer profile ID
return transactionWrapper.doInNewWriteTransaction(new Callable<String>() {
@Override
public String call() throws Exception
{
try {
userAttributeService.setAttribute(user, ATTR_PROFILE_ID, createdCustomerProfileId);
}
catch (RuntimeException e) {
cleanupProfileId(createdCustomerProfileId);
throw e;
}
catch (Error e) {
cleanupProfileId(createdCustomerProfileId);
throw e;
}
return createdCustomerProfileId;
}
});
}
@Override
@MonitoredWork
@GracefulShutdown
@Transactional(readOnly=true, propagation=Propagation.SUPPORTS)
public List<PaymentMethod> getPaymentMethods(DbId<User> userId)
{
String customerProfileId = userAttributeService.getAttribute(userId, ATTR_PROFILE_ID);
if (customerProfileId == null)
return new ArrayList<PaymentMethod>();
return getPaymentMethods(userId, customerProfileId);
}
private List<PaymentMethod> getPaymentMethods(DbId<User> userId, String customerProfileId)
{
Transaction transaction = createTransaction(TransactionType.GET_CUSTOMER_PROFILE);
transaction.setCustomerProfileId(customerProfileId);
Result<Transaction> result = executeTransaction("get payment methods", userId, transaction);
List<PaymentMethod> paymentMethods = new ArrayList<PaymentMethod>();
for (PaymentProfile paymentProfile : result.getCustomerPaymentProfileList()) {
for (Payment payment : paymentProfile.getPaymentList()) {
if (payment.getCreditCard() != null) {
paymentMethods.add(new PaymentMethod(paymentProfile.getCustomerPaymentProfileId(),
payment.getCreditCard().getCreditCardNumber()));
}
}
}
return paymentMethods;
}
@Override
@MonitoredWork
@GracefulShutdown
@Transactional//(readOnly=true, propagation=Propagation.SUPPORTS)
public String getHostedProfilePageToken(DbId<User> userId, String returnUrl)
{
// More than two years later this still isn't in their Java SDK. Oh well, let's just do it
// the stupid way...
String customerProfileId = userAttributeService.getAttribute(userId, ATTR_PROFILE_ID);
if (customerProfileId == null)
customerProfileId = createCustomerProfile(userId);
StringBuffer buffer = new StringBuffer(4096);
buffer.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
buffer.append("<getHostedProfilePageRequest xmlns=\"AnetApi/xml/v1/schema/AnetApiSchema.xsd\">\n");
buffer.append(" <merchantAuthentication>\n");
buffer.append(" <name>" + apiLoginID + "</name>");
buffer.append(" <transactionKey>" + transactionKey + "</transactionKey>\n");
buffer.append(" </merchantAuthentication>\n");
buffer.append(" <customerProfileId>" + customerProfileId + "</customerProfileId> \n");
buffer.append(" <hostedProfileSettings>\n");
buffer.append(" <setting>\n");
buffer.append(" <settingName>hostedProfileReturnUrl</settingName>\n");
buffer.append(" <settingValue>" + returnUrl + "</settingValue>\n");
buffer.append(" </setting>\n");
buffer.append(" </hostedProfileSettings>\n");
buffer.append("</getHostedProfilePageRequest>\n");
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpPost httpPost = new HttpPost(merchant.isSandboxEnvironment() ? TEST_URL : PRODUCTION_URL);
EntityBuilder entityBuilder = EntityBuilder.create();
entityBuilder.setContentType(ContentType.TEXT_XML);
entityBuilder.setContentEncoding("utf-8");
entityBuilder.setText(buffer.toString());
httpPost.setEntity(entityBuilder.build());
try {
CloseableHttpResponse httpResponse = httpClient.execute(httpPost);
String response = EntityUtils.toString(httpResponse.getEntity());
int start = response.indexOf(ELEMENT_TOKEN_OPEN);
if (start == -1)
throw new AuthorizeNetException("error fetching hosted profile page token for " + userId + ", response: " + response);
int end = response.indexOf(ELEMENT_TOKEN_CLOSE);
if (end == -1)
throw new AuthorizeNetException("error fetching hosted profile page token for " + userId + ", response: " + response);
return response.substring(start + ELEMENT_TOKEN_OPEN.length(), end);
}
catch (ClientProtocolException e) {
throw new AuthorizeNetException(e.getMessage(), e);
}
catch (IOException e) {
throw new AuthorizeNetException(e.getMessage(), e);
}
}
@Override
public String authorizePayment(DbId<User> userId, String paymentProfileId, BigDecimal amountToAuthorize)
{
return authorizePayment(userId, paymentProfileId, null, amountToAuthorize);
}
@Override
@MonitoredWork
@GracefulShutdown
public String authorizePayment(DbId<User> userId, String paymentProfileId, String cardCode, BigDecimal amountToAuthorize)
{
String customerProfileId = userAttributeService.getAttribute(userId, ATTR_PROFILE_ID);
if (customerProfileId == null)
throw new OperationDisallowedException(userId + " has no Authorize.Net customer profile");
List<PaymentMethod> paymentMethods = getPaymentMethods(userId, customerProfileId);
PaymentMethod paymentMethodToUse = null;
for (PaymentMethod paymentMethod : paymentMethods)
if (paymentMethod.getPaymentProfileId().equals(paymentProfileId)) {
paymentMethodToUse = paymentMethod;
break;
}
if (paymentMethodToUse == null)
throw new OperationDisallowedException(userId + " does not have paymentProfileId " + paymentProfileId);
Order order = Order.createOrder();
order.setTotalAmount(amountToAuthorize);
PaymentTransaction paymentTransaction = PaymentTransaction.createPaymentTransaction();
paymentTransaction.setTransactionType(net.authorize.TransactionType.AUTH_ONLY);
paymentTransaction.setCustomerPaymentProfileId(paymentProfileId);
paymentTransaction.setOrder(order);
if (cardCode != null)
paymentTransaction.setCardCode(cardCode);
Transaction transaction = createTransaction(TransactionType.CREATE_CUSTOMER_PROFILE_TRANSACTION);
transaction.setPaymentTransaction(paymentTransaction);
transaction.setCustomerProfileId(customerProfileId);
Result<Transaction> result = executeTransaction("authorize", userId, amountToAuthorize, transaction);
Map<ResponseField,String> responseMap = result.getDirectResponseList().get(0).getDirectResponseMap();
ResponseReasonCode responseReasonCode = ResponseReasonCode.findByReasonCode(responseMap.get(ResponseField.RESPONSE_REASON_CODE));
switch (responseReasonCode) {
case RRC_1_1:
log.info("successfully authorized payment of " + amountToAuthorize + " for " + userId + ": " + responseReasonCode);
return responseMap.get(ResponseField.TRANSACTION_ID);
case RRC_4_253:
log.info("successfully authorized (but held for review) payment of " + amountToAuthorize + " for " + userId + ": " + responseReasonCode);
return responseMap.get(ResponseField.TRANSACTION_ID);
default :
log.info("authorization failed in amount of " + amountToAuthorize + " for " + userId + ": " + responseReasonCode);
throw new AuthorizeNetException(responseReasonCode.getReasonText());
}
}
@Override
@MonitoredWork
@GracefulShutdown
public String settlePayment(DbId<User> userId, String transactionId, BigDecimal amountToSettle)
{
String customerProfileId = userAttributeService.getAttribute(userId, ATTR_PROFILE_ID);
if (customerProfileId == null)
throw new OperationDisallowedException(userId + " has no Authorize.Net customer profile");
Order order = Order.createOrder();
order.setTotalAmount(amountToSettle);
PaymentTransaction paymentTransaction = PaymentTransaction.createPaymentTransaction();
paymentTransaction.setTransactionType(net.authorize.TransactionType.PRIOR_AUTH_CAPTURE);
paymentTransaction.setTransactionId(transactionId);
paymentTransaction.setOrder(order);
Transaction transaction = createTransaction(TransactionType.CREATE_CUSTOMER_PROFILE_TRANSACTION);
transaction.setPaymentTransaction(paymentTransaction);
transaction.setCustomerProfileId(customerProfileId);
Result<Transaction> result = executeTransaction("settle", userId, amountToSettle,transaction);
Map<ResponseField,String> responseMap = result.getDirectResponseList().get(0).getDirectResponseMap();
ResponseReasonCode responseReasonCode = ResponseReasonCode.findByReasonCode(responseMap.get(ResponseField.RESPONSE_REASON_CODE));
if (responseReasonCode == ResponseReasonCode.RRC_1_1) {
log.info("successfully settled payment of " + amountToSettle + " for " + userId);
return responseMap.get(ResponseField.TRANSACTION_ID);
}
else
throw new AuthorizeNetException(responseReasonCode.getReasonText());
}
private void cleanupProfileId(String customerProfileId)
{
Transaction transaction = createTransaction(TransactionType.DELETE_CUSTOMER_PROFILE);
transaction.setCustomerProfileId(customerProfileId);
BasicXmlDocument response = net.authorize.util.HttpClient.executeXML(environment, transaction);
Result<Transaction> result = Result.createResult(transaction, response);
if (!result.isOk()) {
recordInterventionRequest("failed to clean up Authorize.Net customer profile id " + customerProfileId + " " +
createErrorMessage(result));
}
}
private Transaction createTransaction(TransactionType transactionType)
{
Transaction transaction = merchant.createCIMTransaction(transactionType);
transaction.setValidationMode(validationMode);
return transaction;
}
private Result<Transaction> executeTransaction(String logOperation, DbId<User> userId, Transaction transaction)
{
return executeTransaction(logOperation, userId, new BigDecimal(0), transaction);
}
private Result<Transaction> executeTransaction(String logOperation, DbId<User> userId, BigDecimal amount, Transaction transaction)
{
BasicXmlDocument response = net.authorize.util.HttpClient.executeXML(environment, transaction);
if (log.isInfoEnabled()) {
BasicXmlDocument request = transaction.getCurrentRequest();
if (request != null) {
log.info(request.dump());
log.info(response.dump());
}
}
Result<Transaction> result = Result.createResult(transaction, response);
List<DirectResponse> directResponses = result.getDirectResponseList();
// check ResponseReasonCode to see if it means we should throw an exception
if (directResponses != null && directResponses.size() > 0) {
Map<ResponseField,String> directResponseMap = directResponses.get(0).getDirectResponseMap();
ResponseCode responseCode = ResponseCode.findByResponseCode(directResponseMap.get(ResponseField.RESPONSE_CODE));
ResponseReasonCode responseReasonCode = ResponseReasonCode.findByReasonCode(directResponseMap.get(ResponseField.RESPONSE_REASON_CODE));
// check exceptionMap to see if we should throw specific exception
Class<? extends AuthorizeNetException> exceptionClass = exceptionMap.get(responseReasonCode);
if (exceptionClass != null) {
log.info(logOperation + " failed in amount of " + amount + " for " + userId + ": " + responseReasonCode);
try {
Constructor<? extends AuthorizeNetException> constructor = exceptionClass.getConstructor(String.class);
throw constructor.newInstance(responseReasonCode.getReasonText());
}
catch (NoSuchMethodException e) { /* fall through */ }
catch (InvocationTargetException e) { /* fall through */ }
catch (IllegalAccessException e) { /* fall through */ }
catch (InstantiationException e) { /* fall through */ }
log.warn("Exception class " + exceptionClass.getSimpleName() + " failed reflection instantiation");
// we know we want an exception but failed to instantiate it. Throw ANE as default
throw new AuthorizeNetException(responseReasonCode.getReasonText());
}
// umbrella processing for DECLINED
if (responseCode == ResponseCode.DECLINED) {
log.info(logOperation + " failed in amount of " + amount + " for " + userId + ": " + responseReasonCode);
throw new PaymentDeclinedException(responseReasonCode.getReasonText());
}
}
// map all other failures (where no DirectResponseMap or RRC may not be in exceptionMap) into ANE
if (!result.isOk()) {
String message = createErrorMessage(result);
log.warn(logOperation + " failed for " + userId + ": " + message);
throw new AuthorizeNetException(message);
}
return result;
}
private String createErrorMessage(Result<Transaction> result)
{
StringBuffer buffer = new StringBuffer();
buffer.append(result.getResultCode());
for (Message message : result.getMessages()) {
buffer.append(" (");
buffer.append(message.getCode());
buffer.append(":");
buffer.append(message.getText());
buffer.append(")");
}
return buffer.toString();
}
@Required
public void setUserService(IUserService userService)
{
this.userService = userService;
}
@Required
public void setUserAttributeService(IUserAttributeService userAttributeService)
{
this.userAttributeService = userAttributeService;
}
@Required
public void setEnvironment(String environment)
{
this.environment = Environment.valueOf(environment);
}
@Required
public void setApiLoginID(String apiLoginID)
{
this.apiLoginID = apiLoginID;
}
@Required
public void setTransactionKey(String transactionKey)
{
this.transactionKey = transactionKey;
}
public void setValidationMode(String validationMode)
{
this.validationMode = ValidationModeType.valueOf(validationMode);
}
@Required
public void setTransactionWrapper(TransactionWrapper transactionWrapper)
{
this.transactionWrapper = transactionWrapper;
}
}