Skip to content

Commit

Permalink
invoice, payment: first pass at making invoice support PENDING payments
Browse files Browse the repository at this point in the history
Make sure the InvoicePayment control plugin supports PENDING payments.

To make it work, this patch:

* implements notifyPendingTransactionOfStateChangedWithPaymentControl
* introduces a new NOTIFICATION_OF_STATE_CHANGE ControlOperation
* ensures that requestData is always populated in CallableWithRequestData (otherwise locks are not re-entrant)

Note that the control state machine already supported the PENDING state (see 179d677).
There is no separate state in that case, the payment attempt ends up in a SUCCESS state while the payment is PENDING. No new attempt is created when completing
the payment through the control APIs.

More work is required around testing:

* Verify Overdue is notified while the payment is PENDING
* Verify a transition PENDING -> PAYMENT_FAILURE schedules retries
* Verify integration with the fixPaymentTransactionState AdminApi

See #625.

Signed-off-by: Pierre-Alexandre Meyer <pierre@mouraf.org>
  • Loading branch information
pierre committed Dec 14, 2016
1 parent 18d9b85 commit e1a72d4
Show file tree
Hide file tree
Showing 14 changed files with 418 additions and 49 deletions.
Expand Up @@ -24,8 +24,6 @@
import java.util.Map;
import java.util.UUID;

import javax.annotation.Nullable;

import org.joda.time.DateTime;
import org.joda.time.LocalDate;
import org.killbill.billing.ErrorCode;
Expand All @@ -48,10 +46,12 @@
import org.killbill.billing.invoice.model.ExternalChargeInvoiceItem;
import org.killbill.billing.payment.api.Payment;
import org.killbill.billing.payment.api.PaymentApiException;
import org.killbill.billing.payment.api.PaymentOptions;
import org.killbill.billing.payment.api.PaymentTransaction;
import org.killbill.billing.payment.api.PluginProperty;
import org.killbill.billing.payment.api.TransactionStatus;
import org.killbill.billing.payment.invoice.InvoicePaymentControlPluginApi;
import org.mockito.Mockito;
import org.skife.jdbi.v2.Handle;
import org.skife.jdbi.v2.tweak.HandleCallback;
import org.testng.Assert;
Expand All @@ -68,8 +68,6 @@

public class TestInvoicePayment extends TestIntegrationBase {



@Test(groups = "slow")
public void testCancellationEOTWithInvoiceItemAdjustmentsOnInvoiceWithMultipleItems() throws Exception {
final int billingDay = 1;
Expand Down Expand Up @@ -129,12 +127,8 @@ public boolean apply(final InvoiceItem input) {
Assert.assertEquals(fourthInvoice.getInvoiceItems().size(), 1);
invoiceChecker.checkInvoice(account.getId(), 4, callContext,
new ExpectedInvoiceItemCheck(new LocalDate(2016, 11, 1), new LocalDate(2016, 12, 1), InvoiceItemType.RECURRING, new BigDecimal("249.95")));


}



@Test(groups = "slow")
public void testPartialPaymentByPaymentPlugin() throws Exception {
// 2012-05-01T00:03:42.000Z
Expand Down Expand Up @@ -599,6 +593,85 @@ public void testWithPaymentFailure() throws Exception {
assertEquals(payments2.get(0).getTransactions().get(1).getProcessedCurrency(), Currency.USD);
}

@Test(groups = "slow")
public void testWithPendingPaymentThenSuccess() throws Exception {
clock.setDay(new LocalDate(2012, 4, 1));

final AccountData accountData = getAccountData(1);
final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
accountChecker.checkAccount(account.getId(), accountData, callContext);

paymentPlugin.makeNextPaymentPending();

createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);

busHandler.pushExpectedEvents(NextEvent.PHASE, NextEvent.INVOICE, NextEvent.PAYMENT);
clock.addDays(30);
assertListenerStatus();

final List<Invoice> invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
assertEquals(invoices.size(), 2);

final Invoice invoice1 = invoices.get(0).getInvoiceItems().get(0).getInvoiceItemType() == InvoiceItemType.RECURRING ?
invoices.get(0) : invoices.get(1);
assertTrue(invoice1.getBalance().compareTo(new BigDecimal("249.95")) == 0);
assertTrue(invoice1.getPaidAmount().compareTo(BigDecimal.ZERO) == 0);
assertTrue(invoice1.getChargedAmount().compareTo(new BigDecimal("249.95")) == 0);
assertEquals(invoice1.getPayments().size(), 1);
assertEquals(invoice1.getPayments().get(0).getAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(invoice1.getPayments().get(0).getCurrency(), Currency.USD);
assertFalse(invoice1.getPayments().get(0).isSuccess());
assertNotNull(invoice1.getPayments().get(0).getPaymentId());

final BigDecimal accountBalance1 = invoiceUserApi.getAccountBalance(account.getId(), callContext);
assertTrue(accountBalance1.compareTo(new BigDecimal("249.95")) == 0);

final List<Payment> payments = paymentApi.getAccountPayments(account.getId(), false, true, ImmutableList.<PluginProperty>of(), callContext);
assertEquals(payments.size(), 1);
assertEquals(payments.get(0).getPurchasedAmount().compareTo(BigDecimal.ZERO), 0);
assertEquals(payments.get(0).getTransactions().size(), 1);
assertEquals(payments.get(0).getTransactions().get(0).getAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(payments.get(0).getTransactions().get(0).getCurrency(), Currency.USD);
assertEquals(payments.get(0).getTransactions().get(0).getProcessedAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(payments.get(0).getTransactions().get(0).getProcessedCurrency(), Currency.USD);
assertEquals(payments.get(0).getTransactions().get(0).getTransactionStatus(), TransactionStatus.PENDING);
assertEquals(payments.get(0).getPaymentAttempts().size(), 1);
assertEquals(payments.get(0).getPaymentAttempts().get(0).getPluginName(), InvoicePaymentControlPluginApi.PLUGIN_NAME);
assertEquals(payments.get(0).getPaymentAttempts().get(0).getStateName(), "SUCCESS");

// Transition the payment to success
final List<String> paymentControlPluginNames = ImmutableList.<String>of(InvoicePaymentControlPluginApi.PLUGIN_NAME);
final PaymentOptions paymentOptions = Mockito.mock(PaymentOptions.class);
Mockito.when(paymentOptions.getPaymentControlPluginNames()).thenReturn(paymentControlPluginNames);

busHandler.pushExpectedEvents(NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
paymentApi.notifyPendingTransactionOfStateChangedWithPaymentControl(account, payments.get(0).getTransactions().get(0).getId(), true, paymentOptions, callContext);
assertListenerStatus();

final Invoice invoice2 = invoiceUserApi.getInvoice(invoice1.getId(), callContext);
assertTrue(invoice2.getBalance().compareTo(BigDecimal.ZERO) == 0);
assertTrue(invoice2.getPaidAmount().compareTo(new BigDecimal("249.95")) == 0);
assertTrue(invoice2.getChargedAmount().compareTo(new BigDecimal("249.95")) == 0);
assertEquals(invoice2.getPayments().size(), 1);
assertTrue(invoice2.getPayments().get(0).isSuccess());

final BigDecimal accountBalance2 = invoiceUserApi.getAccountBalance(account.getId(), callContext);
assertTrue(accountBalance2.compareTo(BigDecimal.ZERO) == 0);

final List<Payment> payments2 = paymentApi.getAccountPayments(account.getId(), false, true, ImmutableList.<PluginProperty>of(), callContext);
assertEquals(payments2.size(), 1);
assertEquals(payments2.get(0).getPurchasedAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(payments2.get(0).getTransactions().size(), 1);
assertEquals(payments2.get(0).getTransactions().get(0).getAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(payments2.get(0).getTransactions().get(0).getCurrency(), Currency.USD);
assertEquals(payments2.get(0).getTransactions().get(0).getProcessedAmount().compareTo(new BigDecimal("249.95")), 0);
assertEquals(payments2.get(0).getTransactions().get(0).getProcessedCurrency(), Currency.USD);
assertEquals(payments2.get(0).getTransactions().get(0).getTransactionStatus(), TransactionStatus.SUCCESS);
assertEquals(payments2.get(0).getPaymentAttempts().size(), 1);
assertEquals(payments2.get(0).getPaymentAttempts().get(0).getPluginName(), InvoicePaymentControlPluginApi.PLUGIN_NAME);
assertEquals(payments2.get(0).getPaymentAttempts().get(0).getStateName(), "SUCCESS");
}

@Test(groups = "slow")
public void testWithIncompletePaymentAttempt() throws Exception {
// 2012-05-01T00:03:42.000Z
Expand Down
Expand Up @@ -100,7 +100,6 @@ public void recordPaymentAttemptInit(final UUID invoiceId, final BigDecimal amou
dao.notifyOfPaymentInit(new InvoicePaymentModelDao(invoicePayment), context);
}


@Override
public void recordPaymentAttemptCompletion(final UUID invoiceId, final BigDecimal amount, final Currency currency, final Currency processedCurrency, final UUID paymentId, final String transactionExternalKey, final DateTime paymentDate, final boolean success, final InternalCallContext context) throws InvoiceApiException {
final InvoicePayment invoicePayment = new DefaultInvoicePayment(InvoicePaymentType.ATTEMPT, paymentId, invoiceId, paymentDate, amount, currency, processedCurrency, transactionExternalKey, success);
Expand Down
Expand Up @@ -732,13 +732,12 @@ public void notifyOfPaymentInit(final InvoicePaymentModelDao invoicePayment, fin
notifyOfPaymentCompletionInternal(invoicePayment, false, context);
}


@Override
public void notifyOfPaymentCompletion(final InvoicePaymentModelDao invoicePayment, final InternalCallContext context) {
notifyOfPaymentCompletionInternal(invoicePayment, true, context);
}

public void notifyOfPaymentCompletionInternal(final InvoicePaymentModelDao invoicePayment, final boolean completion, final InternalCallContext context) {
private void notifyOfPaymentCompletionInternal(final InvoicePaymentModelDao invoicePayment, final boolean completion, final InternalCallContext context) {
transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<Void>() {
@Override
public Void inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
Expand Down
Expand Up @@ -722,7 +722,50 @@ public boolean apply(final PaymentTransaction transaction) {

@Override
public Payment notifyPendingTransactionOfStateChangedWithPaymentControl(final Account account, final UUID paymentTransactionId, final boolean isSuccess, final PaymentOptions paymentOptions, final CallContext callContext) throws PaymentApiException {
throw new IllegalStateException("Not implemented");
final List<String> paymentControlPluginNames = toPaymentControlPluginNames(paymentOptions, callContext);
if (paymentControlPluginNames.isEmpty()) {
return notifyPendingTransactionOfStateChanged(account, paymentTransactionId, isSuccess, callContext);
}

checkNotNullParameter(account, "account");
checkNotNullParameter(paymentTransactionId, "paymentTransactionId");

final String transactionType = "NOTIFY_STATE_CHANGE";
Payment payment = null;
PaymentTransaction paymentTransaction = null;
PaymentApiException exception = null;
try {
logEnterAPICall(log, transactionType, account, null, null, paymentTransactionId, null, null, null, null, null, paymentControlPluginNames);

final InternalCallContext internalCallContext = internalCallContextFactory.createInternalCallContext(account.getId(), callContext);
payment = pluginControlPaymentProcessor.notifyPendingPaymentOfStateChanged(IS_API_PAYMENT, account, paymentTransactionId, isSuccess, paymentControlPluginNames, callContext, internalCallContext);

paymentTransaction = Iterables.<PaymentTransaction>tryFind(payment.getTransactions(),
new Predicate<PaymentTransaction>() {
@Override
public boolean apply(final PaymentTransaction transaction) {
return transaction.getId().equals(paymentTransactionId);
}
}).orNull();
return payment;
} catch (final PaymentApiException e) {
exception = e;
throw e;
} finally {
logExitAPICall(log,
transactionType,
account,
payment != null ? payment.getPaymentMethodId() : null,
payment != null ? payment.getId() : null,
paymentTransaction != null ? paymentTransaction.getId() : null,
paymentTransaction != null ? paymentTransaction.getProcessedAmount() : null,
paymentTransaction != null ? paymentTransaction.getProcessedCurrency() : null,
payment != null ? payment.getExternalKey() : null,
paymentTransaction != null ? paymentTransaction.getExternalKey() : null,
paymentTransaction != null ? paymentTransaction.getTransactionStatus() : null,
paymentControlPluginNames,
exception);
}
}

@Override
Expand Down
Expand Up @@ -46,6 +46,7 @@
import org.killbill.billing.payment.dao.PaymentAttemptModelDao;
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.dao.PluginPropertySerializer;
import org.killbill.billing.payment.dao.PluginPropertySerializer.PluginPropertySerializerException;
import org.killbill.billing.payment.invoice.InvoicePaymentControlPluginApi;
Expand Down Expand Up @@ -198,6 +199,42 @@ public Payment createCredit(final boolean isApiPayment, final Account account, f
callContext, internalCallContext);
}

public Payment notifyPendingPaymentOfStateChanged(final boolean isApiPayment, final Account account, final UUID paymentTransactionId, final boolean isSuccess, final List<String> paymentControlPluginNames, final CallContext callContext, final InternalCallContext internalCallContext) throws PaymentApiException {
final PaymentTransactionModelDao paymentTransactionModelDao = paymentDao.getPaymentTransaction(paymentTransactionId, internalCallContext);
final List<PaymentAttemptModelDao> attempts = paymentDao.getPaymentAttemptByTransactionExternalKey(paymentTransactionModelDao.getTransactionExternalKey(), internalCallContext);
final PaymentAttemptModelDao attempt = Iterables.find(attempts,
new Predicate<PaymentAttemptModelDao>() {
@Override
public boolean apply(final PaymentAttemptModelDao input) {
return input.getTransactionId().equals(paymentTransactionId);
}
});

final Iterable<PluginProperty> pluginProperties;
try {
pluginProperties = PluginPropertySerializer.deserialize(attempt.getPluginProperties());
} catch (final PluginPropertySerializerException e) {
throw new PaymentApiException(e, ErrorCode.PAYMENT_INTERNAL_ERROR, String.format("Unable to deserialize payment attemptId='%s' properties", attempt.getId()));
}

return pluginControlledPaymentAutomatonRunner.run(isApiPayment,
isSuccess,
paymentTransactionModelDao.getTransactionType(),
ControlOperation.NOTIFICATION_OF_STATE_CHANGE,
account,
attempt.getPaymentMethodId(),
paymentTransactionModelDao.getPaymentId(),
attempt.getPaymentExternalKey(),
paymentTransactionId,
paymentTransactionModelDao.getTransactionExternalKey(),
paymentTransactionModelDao.getAmount(),
paymentTransactionModelDao.getCurrency(),
pluginProperties,
paymentControlPluginNames,
callContext,
internalCallContext);
}

public Payment createChargeback(final boolean isApiPayment, final Account account, final UUID paymentId, final String transactionExternalKey, final BigDecimal amount, final Currency currency,
final List<String> paymentControlPluginNames, final CallContext callContext, final InternalCallContext internalCallContext) throws PaymentApiException {
return pluginControlledPaymentAutomatonRunner.run(isApiPayment,
Expand Down
Expand Up @@ -150,8 +150,10 @@ public boolean apply(final PaymentTransactionModelDao input) {
final boolean isApiPayment = true; // unclear
final PaymentStateControlContext paymentStateContext = new PaymentStateControlContext(attempt.toPaymentControlPluginNames(),
isApiPayment,
null,
transaction.getPaymentId(),
attempt.getPaymentExternalKey(),
transaction.getId(),
transaction.getTransactionExternalKey(),
transaction.getTransactionType(),
account,
Expand Down

0 comments on commit e1a72d4

Please sign in to comment.