/* * Copyright 2014-2017 Groupon, Inc * Copyright 2014-2017 The Billing Project, LLC * * The Billing Project licenses this file to you under the Apache License, version 2.0 * (the "License"); you may not use this file except in compliance with the * License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package org.killbill.billing.payment.invoice; import java.math.BigDecimal; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Named; import org.joda.time.DateTime; import org.killbill.billing.ErrorCode; import org.killbill.billing.ObjectType; import org.killbill.billing.account.api.AccountApiException; import org.killbill.billing.account.api.AccountData; import org.killbill.billing.account.api.AccountInternalApi; import org.killbill.billing.callcontext.InternalCallContext; import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.control.plugin.api.OnFailurePaymentControlResult; import org.killbill.billing.control.plugin.api.OnSuccessPaymentControlResult; import org.killbill.billing.control.plugin.api.PaymentApiType; import org.killbill.billing.control.plugin.api.PaymentControlApiException; import org.killbill.billing.control.plugin.api.PaymentControlContext; import org.killbill.billing.control.plugin.api.PaymentControlPluginApi; import org.killbill.billing.control.plugin.api.PriorPaymentControlResult; import org.killbill.billing.invoice.api.Invoice; import org.killbill.billing.invoice.api.InvoiceApiException; import org.killbill.billing.invoice.api.InvoiceInternalApi; import org.killbill.billing.invoice.api.InvoiceItem; import org.killbill.billing.invoice.api.InvoicePayment; import org.killbill.billing.invoice.api.InvoicePaymentType; import org.killbill.billing.invoice.api.InvoiceStatus; import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.payment.api.PluginProperty; import org.killbill.billing.payment.api.TransactionStatus; import org.killbill.billing.payment.api.TransactionType; import org.killbill.billing.payment.dao.PaymentDao; import org.killbill.billing.payment.dao.PaymentModelDao; import org.killbill.billing.payment.dao.PaymentTransactionModelDao; import org.killbill.billing.payment.glue.PaymentModule; import org.killbill.billing.payment.invoice.dao.InvoicePaymentControlDao; import org.killbill.billing.payment.invoice.dao.PluginAutoPayOffModelDao; import org.killbill.billing.payment.retry.BaseRetryService.RetryServiceScheduler; import org.killbill.billing.payment.retry.DefaultFailureCallResult; import org.killbill.billing.payment.retry.DefaultOnSuccessPaymentControlResult; import org.killbill.billing.payment.retry.DefaultPriorPaymentControlResult; import org.killbill.billing.util.api.TagUserApi; import org.killbill.billing.util.callcontext.CallContext; import org.killbill.billing.util.callcontext.InternalCallContextFactory; import org.killbill.billing.util.config.definition.PaymentConfig; import org.killbill.billing.util.tag.ControlTagType; import org.killbill.billing.util.tag.Tag; import org.killbill.clock.Clock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Function; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; public final class InvoicePaymentControlPluginApi implements PaymentControlPluginApi { public static final String CREATED_BY = "InvoicePaymentControlPluginApi"; /* Don't change value String for properties as they are referenced from jaxrs without the constants which are not accessible */ public static final String PLUGIN_NAME = "__INVOICE_PAYMENT_CONTROL_PLUGIN__"; public static final String PROP_IPCD_INVOICE_ID = "IPCD_INVOICE_ID"; public static final String PROP_IPCD_REFUND_IDS_WITH_AMOUNT_KEY = "IPCD_REFUND_IDS_AMOUNTS"; public static final String PROP_IPCD_REFUND_WITH_ADJUSTMENTS = "IPCD_REFUND_WITH_ADJUSTMENTS"; public static final String PROP_IPCD_PAYMENT_ID = "IPCD_PAYMENT_ID"; private final PaymentConfig paymentConfig; private final InvoiceInternalApi invoiceApi; private final TagUserApi tagApi; private final PaymentDao paymentDao; private final InvoicePaymentControlDao controlDao; private final RetryServiceScheduler retryServiceScheduler; private final InternalCallContextFactory internalCallContextFactory; private final Clock clock; private final AccountInternalApi accountApi; private final Logger log = LoggerFactory.getLogger(InvoicePaymentControlPluginApi.class); @Inject public InvoicePaymentControlPluginApi(final PaymentConfig paymentConfig, final InvoiceInternalApi invoiceApi, final TagUserApi tagApi, final PaymentDao paymentDao, final InvoicePaymentControlDao invoicePaymentControlDao, @Named(PaymentModule.RETRYABLE_NAMED) final RetryServiceScheduler retryServiceScheduler, final InternalCallContextFactory internalCallContextFactory, final Clock clock, final AccountInternalApi accountApi) { this.paymentConfig = paymentConfig; this.invoiceApi = invoiceApi; this.tagApi = tagApi; this.paymentDao = paymentDao; this.controlDao = invoicePaymentControlDao; this.retryServiceScheduler = retryServiceScheduler; this.internalCallContextFactory = internalCallContextFactory; this.clock = clock; this.accountApi = accountApi; } @Override public PriorPaymentControlResult priorCall(final PaymentControlContext paymentControlContext, final Iterable<PluginProperty> pluginProperties) throws PaymentControlApiException { final TransactionType transactionType = paymentControlContext.getTransactionType(); Preconditions.checkArgument(paymentControlContext.getPaymentApiType() == PaymentApiType.PAYMENT_TRANSACTION); Preconditions.checkArgument(transactionType == TransactionType.PURCHASE || transactionType == TransactionType.REFUND || transactionType == TransactionType.CHARGEBACK || transactionType == TransactionType.CREDIT); final InternalCallContext internalContext = internalCallContextFactory.createInternalCallContext(paymentControlContext.getAccountId(), paymentControlContext); switch (transactionType) { case PURCHASE: return getPluginPurchaseResult(paymentControlContext, pluginProperties, internalContext); case REFUND: return getPluginRefundResult(paymentControlContext, pluginProperties, internalContext); case CHARGEBACK: return new DefaultPriorPaymentControlResult(false, paymentControlContext.getAmount()); case CREDIT: return getPluginCreditResult(paymentControlContext, pluginProperties, internalContext); default: throw new IllegalStateException("Unexpected transactionType " + transactionType); } } @Override public OnSuccessPaymentControlResult onSuccessCall(final PaymentControlContext paymentControlContext, final Iterable<PluginProperty> pluginProperties) throws PaymentControlApiException { final TransactionType transactionType = paymentControlContext.getTransactionType(); Preconditions.checkArgument(transactionType == TransactionType.PURCHASE || transactionType == TransactionType.REFUND || transactionType == TransactionType.CHARGEBACK || transactionType == TransactionType.CREDIT); final InternalCallContext internalContext = internalCallContextFactory.createInternalCallContext(paymentControlContext.getAccountId(), paymentControlContext); try { final InvoicePayment existingInvoicePayment; switch (transactionType) { case PURCHASE: final UUID invoiceId = getInvoiceId(pluginProperties); existingInvoicePayment = invoiceApi.getInvoicePaymentForAttempt(paymentControlContext.getPaymentId(), internalContext); if (existingInvoicePayment != null && existingInvoicePayment.isSuccess()) { // Only one successful purchase per payment (the invoice could be linked to multiple successful payments though) log.info("onSuccessCall was already completed for purchase paymentId='{}'", paymentControlContext.getPaymentId()); } else { final BigDecimal invoicePaymentAmount; if (paymentControlContext.getCurrency() == paymentControlContext.getProcessedCurrency()) { invoicePaymentAmount = paymentControlContext.getProcessedAmount(); } else { log.warn("processedCurrency='{}' of invoice paymentId='{}' doesn't match invoice currency='{}', assuming it is a full payment", paymentControlContext.getProcessedCurrency(), paymentControlContext.getPaymentId(), paymentControlContext.getCurrency()); invoicePaymentAmount = paymentControlContext.getAmount(); } final PaymentTransactionModelDao paymentTransactionModelDao = paymentDao.getPaymentTransaction(paymentControlContext.getTransactionId(), internalContext); // If it's not SUCCESS, it is PENDING final boolean success = paymentTransactionModelDao.getTransactionStatus() == TransactionStatus.SUCCESS; log.debug("Notifying invoice of {} paymentId='{}', amount='{}', currency='{}', invoiceId='{}'", success ? "successful" : "pending", paymentControlContext.getPaymentId(), invoicePaymentAmount, paymentControlContext.getCurrency(), invoiceId); // For PENDING payments, the attempt will be kept as unsuccessful and an InvoicePaymentErrorInternalEvent sent on the bus (e.g. for Overdue) invoiceApi.recordPaymentAttemptCompletion(invoiceId, invoicePaymentAmount, paymentControlContext.getCurrency(), paymentControlContext.getProcessedCurrency(), paymentControlContext.getPaymentId(), paymentControlContext.getTransactionExternalKey(), paymentControlContext.getCreatedDate(), success, internalContext); } break; case REFUND: final Map<UUID, BigDecimal> idWithAmount = extractIdsWithAmountFromProperties(pluginProperties); final PluginProperty prop = getPluginProperty(pluginProperties, PROP_IPCD_REFUND_WITH_ADJUSTMENTS); final boolean isAdjusted = prop != null ? Boolean.valueOf((String) prop.getValue()) : false; invoiceApi.recordRefund(paymentControlContext.getPaymentId(), paymentControlContext.getAmount(), isAdjusted, idWithAmount, paymentControlContext.getTransactionExternalKey(), internalContext); break; case CHARGEBACK: existingInvoicePayment = invoiceApi.getInvoicePaymentForChargeback(paymentControlContext.getPaymentId(), internalContext); if (existingInvoicePayment != null) { // We don't support partial chargebacks (yet?) log.info("onSuccessCall was already completed for chargeback paymentId='{}'", paymentControlContext.getPaymentId()); } else { final InvoicePayment linkedInvoicePayment = invoiceApi.getInvoicePaymentForAttempt(paymentControlContext.getPaymentId(), internalContext); final BigDecimal amount; final Currency currency; if (linkedInvoicePayment.getCurrency().equals(paymentControlContext.getProcessedCurrency()) && paymentControlContext.getProcessedAmount() != null) { amount = paymentControlContext.getProcessedAmount(); currency = paymentControlContext.getProcessedCurrency(); } else if (linkedInvoicePayment.getCurrency().equals(paymentControlContext.getCurrency()) && paymentControlContext.getAmount() != null) { amount = paymentControlContext.getAmount(); currency = paymentControlContext.getCurrency(); } else { amount = linkedInvoicePayment.getAmount(); currency = linkedInvoicePayment.getCurrency(); } invoiceApi.recordChargeback(paymentControlContext.getPaymentId(), paymentControlContext.getTransactionExternalKey(), amount, currency, internalContext); } break; case CREDIT: final Map<UUID, BigDecimal> idWithAmountMap = extractIdsWithAmountFromProperties(pluginProperties); final PluginProperty properties = getPluginProperty(pluginProperties, PROP_IPCD_REFUND_WITH_ADJUSTMENTS); final boolean isInvoiceAdjusted = properties != null ? Boolean.valueOf((String) properties.getValue()) : false; final PluginProperty legacyPayment = getPluginProperty(pluginProperties, PROP_IPCD_PAYMENT_ID); final UUID paymentId = legacyPayment != null ? (UUID) legacyPayment.getValue() : paymentControlContext.getPaymentId(); invoiceApi.recordRefund(paymentId, paymentControlContext.getAmount(), isInvoiceAdjusted, idWithAmountMap, paymentControlContext.getTransactionExternalKey(), internalContext); break; default: throw new IllegalStateException("Unexpected transactionType " + transactionType); } } catch (final InvoiceApiException e) { log.warn("onSuccessCall failed for attemptId='{}', transactionType='{}'", paymentControlContext.getAttemptPaymentId(), transactionType, e); } return new DefaultOnSuccessPaymentControlResult(); } @Override public OnFailurePaymentControlResult onFailureCall(final PaymentControlContext paymentControlContext, final Iterable<PluginProperty> pluginProperties) throws PaymentControlApiException { final InternalCallContext internalContext = internalCallContextFactory.createInternalCallContext(paymentControlContext.getAccountId(), paymentControlContext); final TransactionType transactionType = paymentControlContext.getTransactionType(); DateTime nextRetryDate = null; switch (transactionType) { case PURCHASE: final UUID invoiceId = getInvoiceId(pluginProperties); try { log.debug("Notifying invoice of failed payment: id={}, amount={}, currency={}, invoiceId={}", paymentControlContext.getPaymentId(), paymentControlContext.getAmount(), paymentControlContext.getCurrency(), invoiceId); invoiceApi.recordPaymentAttemptCompletion(invoiceId, BigDecimal.ZERO, paymentControlContext.getCurrency(), // processed currency may be null so we use currency; processed currency will be updated if/when payment succeeds paymentControlContext.getCurrency(), paymentControlContext.getPaymentId(), paymentControlContext.getTransactionExternalKey(), paymentControlContext.getCreatedDate(), false, internalContext); } catch (final InvoiceApiException e) { log.error("InvoicePaymentControlPluginApi onFailureCall failed ton update invoice for attemptId = " + paymentControlContext.getAttemptPaymentId() + ", transactionType = " + transactionType, e); } nextRetryDate = computeNextRetryDate(paymentControlContext.getPaymentExternalKey(), paymentControlContext.isApiPayment(), internalContext); break; case CREDIT: case REFUND: // We don't retry REFUND break; case CHARGEBACK: try { invoiceApi.recordChargebackReversal(paymentControlContext.getPaymentId(), paymentControlContext.getTransactionExternalKey(), internalContext); } catch (final InvoiceApiException e) { log.warn("onFailureCall failed for attemptId='{}', transactionType='{}'", paymentControlContext.getAttemptPaymentId(), transactionType, e); } break; default: throw new IllegalStateException("Unexpected transactionType " + transactionType); } return new DefaultFailureCallResult(nextRetryDate); } public void process_AUTO_PAY_OFF_removal(final UUID accountId, final InternalCallContext internalCallContext) { final List<PluginAutoPayOffModelDao> entries = controlDao.getAutoPayOffEntry(accountId); for (final PluginAutoPayOffModelDao cur : entries) { // TODO In theory we should pass not only PLUGIN_NAME, but also all the plugin list associated which the original call retryServiceScheduler.scheduleRetry(ObjectType.ACCOUNT, accountId, cur.getAttemptId(), internalCallContext.getTenantRecordId(), ImmutableList.<String>of(PLUGIN_NAME), clock.getUTCNow()); } controlDao.removeAutoPayOffEntry(accountId); } private UUID getInvoiceId(final Iterable<PluginProperty> pluginProperties) throws PaymentControlApiException { final PluginProperty invoiceProp = getPluginProperty(pluginProperties, PROP_IPCD_INVOICE_ID); if (invoiceProp == null || !(invoiceProp.getValue() instanceof String)) { throw new PaymentControlApiException("Failed to retrieve invoiceId: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, String.format("Need to specify a valid invoiceId in property %s", PROP_IPCD_INVOICE_ID))); } return UUID.fromString((String) invoiceProp.getValue()); } private PriorPaymentControlResult getPluginPurchaseResult(final PaymentControlContext paymentControlPluginContext, final Iterable<PluginProperty> pluginProperties, final InternalCallContext internalContext) throws PaymentControlApiException { try { final UUID invoiceId = getInvoiceId(pluginProperties); final Invoice invoice = getAndSanitizeInvoice(invoiceId, internalContext); if (!InvoiceStatus.COMMITTED.equals(invoice.getStatus())) { // abort payment if the invoice status is not COMMITTED return new DefaultPriorPaymentControlResult(true); } // Get account and check if it is child and payment is delegated to parent => abort final AccountData accountData = accountApi.getAccountById(invoice.getAccountId(), internalContext); if ((accountData != null) && (accountData.getParentAccountId() != null) && accountData.isPaymentDelegatedToParent()) { return new DefaultPriorPaymentControlResult(true); } final BigDecimal requestedAmount = validateAndComputePaymentAmount(invoice, paymentControlPluginContext.getAmount(), paymentControlPluginContext.isApiPayment()); final boolean isAborted = requestedAmount.compareTo(BigDecimal.ZERO) == 0; if (!isAborted && paymentControlPluginContext.getPaymentMethodId() == null) { log.warn("Payment for invoiceId='{}' was not triggered, accountId='{}' doesn't have a default payment method", getInvoiceId(pluginProperties), paymentControlPluginContext.getAccountId()); invoiceApi.recordPaymentAttemptCompletion(invoiceId, paymentControlPluginContext.getAmount(), paymentControlPluginContext.getCurrency(), paymentControlPluginContext.getProcessedCurrency(), paymentControlPluginContext.getPaymentId(), paymentControlPluginContext.getTransactionExternalKey(), paymentControlPluginContext.getCreatedDate(), false, internalContext); return new DefaultPriorPaymentControlResult(true); } if (!isAborted && insert_AUTO_PAY_OFF_ifRequired(paymentControlPluginContext, requestedAmount)) { return new DefaultPriorPaymentControlResult(true); } if (paymentControlPluginContext.isApiPayment() && isAborted) { throw new PaymentControlApiException("Abort purchase call: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, String.format("Aborted Payment for invoice %s : invoice balance is = %s, requested payment amount is = %s", invoice.getId(), invoice.getBalance(), paymentControlPluginContext.getAmount()))); } else { // // Insert attempt row with a success = false status to implement a two-phase commit strategy and guard against scenario where payment would go through // but onSuccessCall callback never gets called (leaving the place for a double payment if user retries the operation) // invoiceApi.recordPaymentAttemptInit(invoice.getId(), MoreObjects.firstNonNull(paymentControlPluginContext.getAmount(), BigDecimal.ZERO), paymentControlPluginContext.getCurrency(), paymentControlPluginContext.getCurrency(), // Likely to be null, but we don't care as we use the transactionExternalKey // to match the operation in the checkForIncompleteInvoicePaymentAndRepair logic below paymentControlPluginContext.getPaymentId(), paymentControlPluginContext.getTransactionExternalKey(), paymentControlPluginContext.getCreatedDate(), internalContext); return new DefaultPriorPaymentControlResult(isAborted, requestedAmount); } } catch (final InvoiceApiException e) { throw new PaymentControlApiException(e); } catch (final IllegalArgumentException e) { throw new PaymentControlApiException(e); } catch (AccountApiException e) { throw new PaymentControlApiException(e); } } private PriorPaymentControlResult getPluginRefundResult(final PaymentControlContext paymentControlPluginContext, final Iterable<PluginProperty> pluginProperties, final InternalCallContext internalContext) throws PaymentControlApiException { final Map<UUID, BigDecimal> idWithAmount = extractIdsWithAmountFromProperties(pluginProperties); if ((paymentControlPluginContext.getAmount() == null || paymentControlPluginContext.getAmount().compareTo(BigDecimal.ZERO) == 0) && idWithAmount.size() == 0) { throw new PaymentControlApiException("Abort refund call: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, String.format("Refund for payment, key = %s, aborted: requested refund amount is = %s", paymentControlPluginContext.getPaymentExternalKey(), paymentControlPluginContext.getAmount()))); } final PaymentModelDao payment = paymentDao.getPayment(paymentControlPluginContext.getPaymentId(), internalContext); if (payment == null) { throw new PaymentControlApiException("Unexpected null payment"); } // This will calculate the upper bound on the refund amount based on the invoice items associated with that payment. // Note that we are not checking that other (partial) refund occurred, but if the refund ends up being greater than what is allowed // the call to the gateway would fail; it would need noce to validate on our side though... final BigDecimal amountToBeRefunded = computeRefundAmount(payment.getId(), paymentControlPluginContext.getAmount(), idWithAmount, internalContext); final boolean isAborted = amountToBeRefunded.compareTo(BigDecimal.ZERO) == 0; if (paymentControlPluginContext.isApiPayment() && isAborted) { throw new PaymentControlApiException("Abort refund call: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, String.format("Refund for payment %s aborted : invoice item sum amount is %s, requested refund amount is = %s", payment.getId(), amountToBeRefunded, paymentControlPluginContext.getAmount()))); } final PluginProperty prop = getPluginProperty(pluginProperties, PROP_IPCD_REFUND_WITH_ADJUSTMENTS); final boolean isAdjusted = prop != null ? Boolean.valueOf((String) prop.getValue()) : false; if (isAdjusted) { try { invoiceApi.validateInvoiceItemAdjustments(paymentControlPluginContext.getPaymentId(), idWithAmount, internalContext); } catch (InvoiceApiException e) { throw new PaymentControlApiException(String.format("Refund for payment %s aborted", payment.getId()), new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, e.getMessage())); } } return new DefaultPriorPaymentControlResult(isAborted, amountToBeRefunded); } private PriorPaymentControlResult getPluginCreditResult(final PaymentControlContext paymentControlPluginContext, final Iterable<PluginProperty> pluginProperties, final InternalCallContext internalContext) throws PaymentControlApiException { // TODO implement return new DefaultPriorPaymentControlResult(false, paymentControlPluginContext.getAmount()); } private Map<UUID, BigDecimal> extractIdsWithAmountFromProperties(final Iterable<PluginProperty> properties) { final PluginProperty prop = getPluginProperty(properties, PROP_IPCD_REFUND_IDS_WITH_AMOUNT_KEY); if (prop == null) { return ImmutableMap.<UUID, BigDecimal>of(); } return (Map<UUID, BigDecimal>) prop.getValue(); } private PluginProperty getPluginProperty(final Iterable<PluginProperty> properties, final String propertyName) { return Iterables.tryFind(properties, new Predicate<PluginProperty>() { @Override public boolean apply(final PluginProperty input) { return input.getKey().equals(propertyName); } }).orNull(); } private BigDecimal computeRefundAmount(final UUID paymentId, @Nullable final BigDecimal specifiedRefundAmount, final Map<UUID, BigDecimal> invoiceItemIdsWithAmounts, final InternalTenantContext context) throws PaymentControlApiException { if (specifiedRefundAmount != null) { if (specifiedRefundAmount.compareTo(BigDecimal.ZERO) <= 0) { throw new PaymentControlApiException("Failed to compute refund: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, "You need to specify a positive refund amount")); } return specifiedRefundAmount; } try { final List<InvoiceItem> items = invoiceApi.getInvoiceForPaymentId(paymentId, context).getInvoiceItems(); BigDecimal amountFromItems = BigDecimal.ZERO; for (final UUID itemId : invoiceItemIdsWithAmounts.keySet()) { final BigDecimal specifiedItemAmount = invoiceItemIdsWithAmounts.get(itemId); final BigDecimal itemAmount = getAmountFromItem(items, itemId); if (specifiedItemAmount != null && (specifiedItemAmount.compareTo(BigDecimal.ZERO) <= 0 || specifiedItemAmount.compareTo(itemAmount) > 0)) { throw new PaymentControlApiException("Failed to compute refund: ", new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, "You need to specify a valid invoice item amount")); } amountFromItems = amountFromItems.add(MoreObjects.firstNonNull(specifiedItemAmount, itemAmount)); } return amountFromItems; } catch (final InvoiceApiException e) { throw new PaymentControlApiException(e); } } private BigDecimal getAmountFromItem(final List<InvoiceItem> items, final UUID itemId) throws PaymentControlApiException { for (final InvoiceItem item : items) { if (item.getId().equals(itemId)) { return item.getAmount(); } } throw new PaymentControlApiException(String.format("Unable to find invoice item for id %s", itemId), new PaymentApiException(ErrorCode.PAYMENT_PLUGIN_EXCEPTION, "Invalid plugin properties")); } private DateTime computeNextRetryDate(final String paymentExternalKey, final boolean isApiAPayment, final InternalCallContext internalContext) { // Don't retry call that come from API. if (isApiAPayment) { return null; } final List<PaymentTransactionModelDao> purchasedTransactions = getPurchasedTransactions(paymentExternalKey, internalContext); if (purchasedTransactions.size() == 0) { return null; } final PaymentTransactionModelDao lastTransaction = purchasedTransactions.get(purchasedTransactions.size() - 1); switch (lastTransaction.getTransactionStatus()) { case PAYMENT_FAILURE: return getNextRetryDateForPaymentFailure(purchasedTransactions, internalContext); case PLUGIN_FAILURE: return getNextRetryDateForPluginFailure(purchasedTransactions, internalContext); case UNKNOWN: default: return null; } } private DateTime getNextRetryDateForPaymentFailure(final List<PaymentTransactionModelDao> purchasedTransactions, final InternalCallContext internalContext) { DateTime result = null; final List<Integer> retryDays = paymentConfig.getPaymentFailureRetryDays(internalContext); final int attemptsInState = getNumberAttemptsInState(purchasedTransactions, TransactionStatus.PAYMENT_FAILURE); final int retryCount = (attemptsInState - 1) >= 0 ? (attemptsInState - 1) : 0; if (retryCount < retryDays.size()) { final int retryInDays; final DateTime nextRetryDate = clock.getUTCNow(); try { retryInDays = retryDays.get(retryCount); result = nextRetryDate.plusDays(retryInDays); log.debug("Next retryDate={}, retryInDays={}, retryCount={}, now={}", result, retryInDays, retryCount, clock.getUTCNow()); } catch (final NumberFormatException ex) { log.error("Could not get retry day for retry count {}", retryCount); } } return result; } private DateTime getNextRetryDateForPluginFailure(final List<PaymentTransactionModelDao> purchasedTransactions, final InternalCallContext internalContext) { DateTime result = null; final int attemptsInState = getNumberAttemptsInState(purchasedTransactions, TransactionStatus.PLUGIN_FAILURE); final int retryAttempt = (attemptsInState - 1) >= 0 ? (attemptsInState - 1) : 0; if (retryAttempt < paymentConfig.getPluginFailureRetryMaxAttempts(internalContext)) { int nbSec = paymentConfig.getPluginFailureInitialRetryInSec(internalContext); int remainingAttempts = retryAttempt; while (--remainingAttempts > 0) { nbSec = nbSec * paymentConfig.getPluginFailureRetryMultiplier(internalContext); } result = clock.getUTCNow().plusSeconds(nbSec); log.debug("Next retryDate={}, retryAttempt={}, now={}", result, retryAttempt, clock.getUTCNow()); } return result; } private int getNumberAttemptsInState(final Collection<PaymentTransactionModelDao> allTransactions, final TransactionStatus... statuses) { if (allTransactions == null || allTransactions.size() == 0) { return 0; } return Collections2.filter(allTransactions, new Predicate<PaymentTransactionModelDao>() { @Override public boolean apply(final PaymentTransactionModelDao input) { for (final TransactionStatus cur : statuses) { if (input.getTransactionStatus() == cur) { return true; } } return false; } }).size(); } private List<PaymentTransactionModelDao> getPurchasedTransactions(final String paymentExternalKey, final InternalCallContext internalContext) { final PaymentModelDao payment = paymentDao.getPaymentByExternalKey(paymentExternalKey, internalContext); if (payment == null) { return Collections.emptyList(); } final List<PaymentTransactionModelDao> transactions = paymentDao.getTransactionsForPayment(payment.getId(), internalContext); if (transactions == null || transactions.size() == 0) { return Collections.emptyList(); } return ImmutableList.copyOf(Iterables.filter(transactions, new Predicate<PaymentTransactionModelDao>() { @Override public boolean apply(final PaymentTransactionModelDao input) { return input.getTransactionType() == TransactionType.PURCHASE; } })); } private Invoice getAndSanitizeInvoice(final UUID invoiceId, final InternalCallContext context) throws InvoiceApiException { final Invoice invoicePriorRebalancing = invoiceApi.getInvoiceById(invoiceId, context); invoiceApi.consumeExistingCBAOnAccountWithUnpaidInvoices(invoicePriorRebalancing.getAccountId(), context); final Invoice invoice = invoiceApi.getInvoiceById(invoiceId, context); if (checkForIncompleteInvoicePaymentAndRepair(invoice, context)) { // Fetch new repaired 'invoice' return invoiceApi.getInvoiceById(invoiceId, context); } else { return invoice; } } private boolean checkForIncompleteInvoicePaymentAndRepair(final Invoice invoice, final InternalCallContext internalContext) throws InvoiceApiException { final List<InvoicePayment> invoicePayments = invoice.getPayments(); // Look for ATTEMPT matching that invoiceId that are not successful and extract matching paymentTransaction final InvoicePayment incompleteInvoicePayment = Iterables.tryFind(invoicePayments, new Predicate<InvoicePayment>() { @Override public boolean apply(final InvoicePayment input) { return input.getType() == InvoicePaymentType.ATTEMPT && !input.isSuccess(); } }).orNull(); // If such (incomplete) paymentTransaction exists, verify the state of the payment transaction if (incompleteInvoicePayment != null) { final String transactionExternalKey = incompleteInvoicePayment.getPaymentCookieId(); final List<PaymentTransactionModelDao> transactions = paymentDao.getPaymentTransactionsByExternalKey(transactionExternalKey, internalContext); final PaymentTransactionModelDao successfulTransaction = Iterables.tryFind(transactions, new Predicate<PaymentTransactionModelDao>() { @Override public boolean apply(final PaymentTransactionModelDao input) { // // In reality this is more tricky because the matching transaction could be an UNKNOWN or PENDING (unsupported by the plugin) state // In case of UNKNOWN, we don't know what to do: fixing it could result in not paying, and not fixing it could result in double payment // Current code ignores it, which means we might end up in doing a double payment in that very edgy scenario, and customer would have to request a refund. // return input.getTransactionStatus() == TransactionStatus.SUCCESS; } }).orNull(); if (successfulTransaction != null) { log.info(String.format("Detected an incomplete invoicePayment row for invoiceId='%s' and transactionExternalKey='%s', will correct status", invoice.getId(), successfulTransaction.getTransactionExternalKey())); invoiceApi.recordPaymentAttemptCompletion(invoice.getId(), successfulTransaction.getAmount(), successfulTransaction.getCurrency(), successfulTransaction.getProcessedCurrency(), successfulTransaction.getPaymentId(), successfulTransaction.getTransactionExternalKey(), successfulTransaction.getCreatedDate(), true, internalContext); return true; } } return false; } private BigDecimal validateAndComputePaymentAmount(final Invoice invoice, @Nullable final BigDecimal inputAmount, final boolean isApiPayment) { if (invoice.getBalance().compareTo(BigDecimal.ZERO) <= 0) { log.info("invoiceId='{}' has already been paid", invoice.getId()); return BigDecimal.ZERO; } if (isApiPayment && inputAmount != null && invoice.getBalance().compareTo(inputAmount) < 0) { log.info("invoiceId='{}' has a balance='{}' < retry paymentAmount='{}'", invoice.getId(), invoice.getBalance().floatValue(), inputAmount.floatValue()); return BigDecimal.ZERO; } if (inputAmount == null) { return invoice.getBalance(); } else { return invoice.getBalance().compareTo(inputAmount) < 0 ? invoice.getBalance() : inputAmount; } } private boolean insert_AUTO_PAY_OFF_ifRequired(final PaymentControlContext paymentControlContext, final BigDecimal computedAmount) { if (paymentControlContext.isApiPayment() || !isAccountAutoPayOff(paymentControlContext.getAccountId(), paymentControlContext)) { return false; } final PluginAutoPayOffModelDao data = new PluginAutoPayOffModelDao(paymentControlContext.getAttemptPaymentId(), paymentControlContext.getPaymentExternalKey(), paymentControlContext.getTransactionExternalKey(), paymentControlContext.getAccountId(), PLUGIN_NAME, paymentControlContext.getPaymentId(), paymentControlContext.getPaymentMethodId(), computedAmount, paymentControlContext.getCurrency(), CREATED_BY, clock.getUTCNow()); controlDao.insertAutoPayOff(data); return true; } private boolean isAccountAutoPayOff(final UUID accountId, final CallContext callContext) { final List<Tag> accountTags = tagApi.getTagsForAccount(accountId, false, callContext); return ControlTagType.isAutoPayOff(Collections2.transform(accountTags, new Function<Tag, UUID>() { @Override public UUID apply(final Tag tag) { return tag.getTagDefinitionId(); } })); } }