From fd9c895961598120139f90a2888db8b7788bf0c9 Mon Sep 17 00:00:00 2001 From: Soeren Kress Date: Mon, 29 Mar 2021 11:57:26 +0200 Subject: [PATCH 1/3] Add support for SEPA direct debit payment method The previous flow for credit cards which uses Stripe's Payment Intents API (1$ card auth which is later voided) does not work with SEPA direct debit, as SEPA direct debit can only be used with an automatic capture. To keep the overall amount of code as low as possible the credit card flow was also adapted such that there is only one flow for both payment methods. Instead of using the Payment Intents API the plugin now uses Setup Intents, i.e. the payment method is only setup for later payments. --- README.md | 15 ++- .../plugin/stripe/StripeCheckoutServlet.java | 9 +- .../plugin/stripe/StripePaymentPluginApi.java | 120 +++++++++++------- .../plugin/stripe/StripePluginProperties.java | 57 +++++++++ .../billing/plugin/stripe/TestBase.java | 36 +++++- .../stripe/TestStripePaymentPluginApi.java | 41 ++++-- 6 files changed, 211 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 0913cf2..7738f64 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,15 @@ curl -v \ -H "X-Killbill-Comment: demo" \ "http://127.0.0.1:8080/plugins/killbill-stripe/checkout?kbAccountId=" ``` + +The default is to only allow credit cards. If you want to enable sepa direct debit payments, you need to include the `paymentMethodTypes` option, i.e. change the URL of your POST request +to `http://127.0.0.1:8080/plugins/killbill-stripe/checkout?kbAccountId=&paymentMethodTypes=card&paymentMethodTypes=sepa_debit`. + 3. Redirect the user to the Stripe checkout page. The `sessionId` is returned as part of the `formFields` (`id` key): ```javascript stripe.redirectToCheckout({ sessionId: 'cs_test_XXX' }); ``` -4. After entering the credit card, a $1 authorization will be triggered. Call `addPaymentMethod` to create the Stripe payment method and pass the `sessionId` in the plugin properties. This will void the authorization (if successful) and store the payment method in Kill Bill: +4. After entering the credit card or bank account details, the payment method will be available in Stripe. Call `addPaymentMethod` to store the payment method in Kill Bill: ```bash curl -v \ -X POST \ @@ -158,6 +162,15 @@ curl -v \ -H "X-Killbill-Comment: demo" \ "http://127.0.0.1:8080/1.0/kb/accounts//paymentMethods/refresh" ``` +## Development + +For testing you need to add your Stripe public and private key to `src/test/resources/stripe.properties`: + +``` +org.killbill.billing.plugin.stripe.apiKey=sk_test_XXX +org.killbill.billing.plugin.stripe.publicKey=pk_test_XXX +``` + ## About Kill Bill is the leading Open-Source Subscription Billing & Payments Platform. For more information about the project, go to https://killbill.io/. diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeCheckoutServlet.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeCheckoutServlet.java index ef21fa9..f50c137 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripeCheckoutServlet.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeCheckoutServlet.java @@ -17,6 +17,7 @@ package org.killbill.billing.plugin.stripe; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -62,18 +63,16 @@ public StripeCheckoutServlet(final OSGIKillbillClock clock, public Result createSession(@Named("kbAccountId") final UUID kbAccountId, @Named("successUrl") final Optional successUrl, @Named("cancelUrl") final Optional cancelUrl, - @Named("lineItemName") final Optional lineItemName, @Named("kbInvoiceId") final Optional kbInvoiceId, - @Named("lineItemAmount") final Optional lineItemAmount, + @Named("paymentMethodTypes") final Optional> paymentMethodTypes, @Local @Named("killbill_tenant") final Tenant tenant) throws JsonProcessingException, PaymentPluginApiException { final CallContext context = new PluginCallContext(StripeActivator.PLUGIN_NAME, clock.getClock().getUTCNow(), kbAccountId, tenant.getId()); final ImmutableList customFields = ImmutableList.of( new PluginProperty("kb_account_id", kbAccountId.toString(), false), new PluginProperty("kb_invoice_id", kbInvoiceId.orElse(null), false), - new PluginProperty("success_url", successUrl.orElse("https://example.com/success"), false), + new PluginProperty("success_url", successUrl.orElse("https://example.com/success?sessionId={CHECKOUT_SESSION_ID}"), false), new PluginProperty("cancel_url", cancelUrl.orElse("https://example.com/cancel"), false), - new PluginProperty("line_item_name", lineItemName.orElse("Authorization charge"), false), - new PluginProperty("line_item_amount", lineItemAmount.orElse(100), false)); + new PluginProperty("payment_method_types", paymentMethodTypes.orElse(null), false)); final HostedPaymentPageFormDescriptor hostedPaymentPageFormDescriptor = stripePaymentPluginApi.buildFormDescriptor(kbAccountId, customFields, ImmutableList.of(), diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java index 868f3d5..c1baae1 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java @@ -79,6 +79,7 @@ import com.stripe.model.PaymentSource; import com.stripe.model.PaymentSourceCollection; import com.stripe.model.Refund; +import com.stripe.model.SetupIntent; import com.stripe.model.Source; import com.stripe.model.Token; import com.stripe.model.checkout.Session; @@ -87,6 +88,17 @@ public class StripePaymentPluginApi extends PluginPaymentPluginApi { + private enum CaptureMethod { + AUTOMATIC("automatic"), + MANUAL("manual"); + + public final String value; + + CaptureMethod(String value) { + this.value = value; + } + } + private static final Logger logger = LoggerFactory.getLogger(StripePaymentPluginApi.class); public static final String PROPERTY_FROM_HPP = "fromHPP"; @@ -95,12 +107,8 @@ public class StripePaymentPluginApi extends PluginPaymentPluginApi metadataFilter = ImmutableList.of( - "line_item_name", - "line_item_amount", - "line_item_currency", - "line_item_quantity"); + + static final List metadataFilter = ImmutableList.of("payment_method_types"); // needed for API calls to expand the response to contain the 'Sources' // https://stripe.com/docs/api/expanding_objects?lang=java @@ -252,31 +260,27 @@ public void addPaymentMethod(final UUID kbAccountId, final UUID kbPaymentMethodI throw new PaymentPluginApiException("INTERNAL", "Unable to add payment method: missing StripeHppRequestsRecord for sessionId " + sessionId); } - final String paymentIntentId = (String) StripeDao.fromAdditionalData(hppRecord.getAdditionalData()).get("payment_intent_id"); - final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, requestOptions); - if ("requires_capture".equals(paymentIntent.getStatus())) { - // Void it - logger.info("Voiding Stripe transaction {}", paymentIntent.getId()); - paymentIntent.cancel(requestOptions); - + final String setupIntentId = (String) StripeDao.fromAdditionalData(hppRecord.getAdditionalData()).get("setup_intent_id"); + final SetupIntent setupIntent = SetupIntent.retrieve(setupIntentId, requestOptions); + if ("succeeded".equals(setupIntent.getStatus())) { final String existingCustomerId = getCustomerIdNoException(kbAccountId, context); if (existingCustomerId == null) { // Add magic custom field - logger.info("Mapping kbAccountId {} to Stripe customer {}", kbAccountId, paymentIntent.getCustomer()); + logger.info("Mapping kbAccountId {} to Stripe customer {}", kbAccountId, setupIntent.getCustomer()); killbillAPI.getCustomFieldUserApi().addCustomFields(ImmutableList.of(new PluginCustomField(kbAccountId, ObjectType.ACCOUNT, "STRIPE_CUSTOMER_ID", - paymentIntent.getCustomer(), + setupIntent.getCustomer(), clock.getUTCNow())), context); - } else if (!existingCustomerId.equals(paymentIntent.getCustomer())) { - throw new PaymentPluginApiException("USER", "Unable to add payment method : paymentIntent customerId is " + paymentIntent.getCustomer() + " but account already mapped to " + existingCustomerId); + } else if (!existingCustomerId.equals(setupIntent.getCustomer())) { + throw new PaymentPluginApiException("USER", "Unable to add payment method : setupIntent customerId is " + setupIntent.getCustomer() + " but account already mapped to " + existingCustomerId); } // Used below to create the row in the plugin // TODO This implicitly assumes the payment method type if "payment_method", is this always true? - paymentMethodIdInStripe = paymentIntent.getPaymentMethod(); + paymentMethodIdInStripe = setupIntent.getPaymentMethod(); } else { - throw new PaymentPluginApiException("EXTERNAL", "Unable to add payment method: paymentIntent is " + paymentIntent.getStatus()); + throw new PaymentPluginApiException("EXTERNAL", "Unable to add payment method: setupIntent status is: " + setupIntent.getStatus()); } } catch (final SQLException e) { throw new PaymentPluginApiException("Unable to add payment method", e); @@ -425,10 +429,13 @@ public List getPaymentMethods(final UUID kbAccountId, f // Start with PaymentMethod... final Map paymentMethodParams = new HashMap(); paymentMethodParams.put("customer", stripeCustomerId); - // Only supported type by Stripe for now paymentMethodParams.put("type", "card"); - final Iterable stripePaymentMethods = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(); - syncPaymentMethods(kbAccountId, stripePaymentMethods, existingPaymentMethodByStripeId, stripeObjectsTreated, context); + final Iterable stripePaymentMethodsCard = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(); + syncPaymentMethods(kbAccountId, stripePaymentMethodsCard, existingPaymentMethodByStripeId, stripeObjectsTreated, context); + + paymentMethodParams.put("type", "sepa_debit"); + final Iterable stripePaymentMethodsSepaDebit = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(); + syncPaymentMethods(kbAccountId, stripePaymentMethodsSepaDebit, existingPaymentMethodByStripeId, stripeObjectsTreated, context); // Then go through the sources final PaymentSourceCollection psc = Customer.retrieve(stripeCustomerId, expandSourcesParams, requestOptions).getSources(); @@ -649,8 +656,32 @@ RequestOptions buildRequestOptions(final TenantContext context) { @Override public HostedPaymentPageFormDescriptor buildFormDescriptor(final UUID kbAccountId, final Iterable customFields, final Iterable properties, final CallContext context) throws PaymentPluginApiException { + final RequestOptions requestOptions = buildRequestOptions(context); + final Account account = getAccount(kbAccountId, context); - final String defaultCurrency = account.getCurrency() != null ? account.getCurrency().name() : "USD"; + + String stripeCustomerId = getCustomerIdNoException(kbAccountId, context); + if (stripeCustomerId == null) { + // add new customer to stripe account + Map address = new HashMap<>(); + address.put("city", account.getCity()); + address.put("country", account.getCountry()); + address.put("line1", account.getAddress1()); + address.put("line2", account.getAddress2()); + address.put("postal_code", account.getPostalCode()); + address.put("state", account.getStateOrProvince()); + Map params = new HashMap<>(); + params.put("email", account.getEmail()); + params.put("name", account.getName()); + params.put("address", address); + params.put("description", "created via KB"); + try { + Customer customer = Customer.create(params, requestOptions); + stripeCustomerId = customer.getId(); + } catch (StripeException e) { + throw new PaymentPluginApiException("Unable to create Stripe customer", e); + } + } final Map params = new HashMap(); final Map metadata = new HashMap(); @@ -658,33 +689,23 @@ public HostedPaymentPageFormDescriptor buildFormDescriptor(final UUID kbAccountI .filter(entry -> !metadataFilter.contains(entry.getKey())) .forEach(p -> metadata.put(p.getKey(), p.getValue())); params.put("metadata", metadata); - - // Stripe doesn't support anything else yet - final ArrayList paymentMethodTypes = new ArrayList<>(); - paymentMethodTypes.add("card"); - params.put("payment_method_types", paymentMethodTypes); - - final ArrayList> lineItems = new ArrayList<>(); - final HashMap lineItem = new HashMap(); - lineItem.put("name", PluginProperties.getValue("line_item_name", "Authorization charge", customFields)); - lineItem.put("amount", PluginProperties.getValue("line_item_amount", "100", customFields)); - lineItem.put("currency", PluginProperties.getValue("line_item_currency", defaultCurrency, customFields)); - lineItem.put("quantity", PluginProperties.getValue("line_item_quantity", "1", customFields)); - lineItems.add(lineItem); - params.put("line_items", lineItems); - - final HashMap paymentIntentData = new HashMap(); - // Auth only - paymentIntentData.put("capture_method", "manual"); - params.put("payment_intent_data", paymentIntentData); - - params.put("success_url", PluginProperties.getValue("success_url", "https://example.com/success", customFields)); + params.put("customer", stripeCustomerId); + + final List defaultPaymentMethodTypes = new ArrayList(); + defaultPaymentMethodTypes.add("card"); + final PluginProperty customPaymentMethods = StreamSupport.stream(customFields.spliterator(), false) + .filter(entry -> "payment_method_types".equals(entry.getKey())) + .findFirst().orElse(null); + params.put("payment_method_types", customPaymentMethods != null && customPaymentMethods.getValue() != null ? customPaymentMethods.getValue() : defaultPaymentMethodTypes); + + params.put("mode", "setup"); + params.put("success_url", PluginProperties.getValue("success_url", "https://example.com/success?sessionId={CHECKOUT_SESSION_ID}", customFields)); params.put("cancel_url", PluginProperties.getValue("cancel_url", "https://example.com/cancel", customFields)); final StripeConfigProperties stripeConfigProperties = stripeConfigPropertiesConfigurationHandler.getConfigurable(context.getTenantId()); - + try { logger.info("Creating Stripe session"); - final Session session = Session.create(params, buildRequestOptions(context)); + final Session session = Session.create(params, requestOptions); dao.addHppRequest(kbAccountId, null, @@ -733,10 +754,12 @@ private PaymentTransactionInfoPlugin executeInitialTransaction(final Transaction public PaymentIntent execute(final Account account, final StripePaymentMethodsRecord paymentMethodsRecord) throws StripeException { final RequestOptions requestOptions = buildRequestOptions(context); + final CaptureMethod captureMethod = transactionType == TransactionType.AUTHORIZE ? CaptureMethod.MANUAL : CaptureMethod.AUTOMATIC; + final Map paymentIntentParams = new HashMap<>(); paymentIntentParams.put("amount", KillBillMoney.toMinorUnits(currency.toString(), amount)); paymentIntentParams.put("currency", currency.toString()); - paymentIntentParams.put("capture_method", transactionType == TransactionType.AUTHORIZE ? "manual" : "automatic"); + paymentIntentParams.put("capture_method", captureMethod.value); // TODO Do we need to switch to manual confirmation to be able to set off_session=recurring? paymentIntentParams.put("confirm", true); // See https://stripe.com/docs/api/payment_intents/create#create_payment_intent-return_url @@ -770,6 +793,9 @@ public PaymentIntent execute(final Account account, final StripePaymentMethodsRe final ImmutableList.Builder paymentMethodTypesBuilder = ImmutableList.builder(); paymentMethodTypesBuilder.add("card"); + if (captureMethod == CaptureMethod.AUTOMATIC && currency == Currency.EUR) { + paymentMethodTypesBuilder.add("sepa_debit"); + } if (transactionType == TransactionType.PURCHASE && currency == Currency.USD) { // See https://groups.google.com/forum/?#!msg/killbilling-users/li3RNs-YmIA/oaUrBElMFQAJ paymentMethodTypesBuilder.add("ach_debit"); diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java b/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java index 9cf9568..2377d54 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java @@ -28,6 +28,8 @@ import com.stripe.model.PaymentMethod; import com.stripe.model.PaymentMethod.Card; import com.stripe.model.PaymentSource; +import com.stripe.model.SetupIntent; +import com.stripe.model.SetupIntent.PaymentMethodOptions; import com.stripe.model.Source; import com.stripe.model.Source.AchDebit; import com.stripe.model.Token; @@ -78,6 +80,16 @@ public static Map toAdditionalDataMap(final PaymentSource stripe additionalDataMap.put("ach_debit_routing_number", achDebit.getRoutingNumber()); additionalDataMap.put("ach_debit_type", achDebit.getType()); } + final Source.SepaDebit sepaDebit = stripeSource.getSepaDebit(); + if (sepaDebit != null) { + additionalDataMap.put("sepa_debit_bank_code", sepaDebit.getBankCode()); + additionalDataMap.put("sepa_debit_branch_code", sepaDebit.getBranchCode()); + additionalDataMap.put("sepa_debit_country", sepaDebit.getCountry()); + additionalDataMap.put("sepa_debit_fingerprint", sepaDebit.getFingerprint()); + additionalDataMap.put("sepa_debit_last4", sepaDebit.getLast4()); + additionalDataMap.put("sepa_debit_mandate_reference", sepaDebit.getMandateReference()); + additionalDataMap.put("sepa_debit_mandate_url", sepaDebit.getMandateUrl()); + } additionalDataMap.put("created", stripeSource.getCreated()); additionalDataMap.put("customer_id", stripeSource.getCustomer()); additionalDataMap.put("id", stripeSource.getId()); @@ -143,6 +155,15 @@ public static Map toAdditionalDataMap(final PaymentMethod stripe additionalDataMap.put("card_wallet_type", card.getWallet().getType()); } } + final PaymentMethod.SepaDebit sepaDebit = stripePaymentMethod.getSepaDebit(); + if (sepaDebit != null) { + additionalDataMap.put("sepa_debit_bank_code", sepaDebit.getBankCode()); + additionalDataMap.put("sepa_debit_branch_code", sepaDebit.getBranchCode()); + additionalDataMap.put("sepa_debit_country", sepaDebit.getCountry()); + additionalDataMap.put("sepa_debit_fingerprint", sepaDebit.getFingerprint()); + additionalDataMap.put("sepa_debit_last4", sepaDebit.getLast4()); + } + additionalDataMap.put("created", stripePaymentMethod.getCreated()); additionalDataMap.put("customer_id", stripePaymentMethod.getCustomer()); additionalDataMap.put("id", stripePaymentMethod.getId()); @@ -218,6 +239,41 @@ public static Map toAdditionalDataMap(final PaymentIntent stripe return additionalDataMap; } + public static Map toAdditionalDataMap(final SetupIntent stripeSetupIntent) { + final Map additionalDataMap = new HashMap(); + + additionalDataMap.put("application", stripeSetupIntent.getApplication()); + additionalDataMap.put("cancellation_reason", stripeSetupIntent.getCancellationReason()); + additionalDataMap.put("created", stripeSetupIntent.getCreated()); + additionalDataMap.put("customer_id", stripeSetupIntent.getCustomer()); + additionalDataMap.put("description", stripeSetupIntent.getDescription()); + additionalDataMap.put("id", stripeSetupIntent.getId()); + additionalDataMap.put("last_setup_error", stripeSetupIntent.getLastSetupError()); + additionalDataMap.put("latest_attempt", stripeSetupIntent.getLatestAttempt()); + additionalDataMap.put("livemode", stripeSetupIntent.getLivemode()); + additionalDataMap.put("mandate", stripeSetupIntent.getMandate()); + additionalDataMap.put("metadata", stripeSetupIntent.getMetadata()); + additionalDataMap.put("next_action", stripeSetupIntent.getNextAction()); + additionalDataMap.put("object", stripeSetupIntent.getObject()); + additionalDataMap.put("on_behalf_of", stripeSetupIntent.getOnBehalfOf()); + additionalDataMap.put("payment_method_id", stripeSetupIntent.getPaymentMethod()); + final PaymentMethodOptions paymentMethodOptions = stripeSetupIntent.getPaymentMethodOptions(); + if (paymentMethodOptions != null ) { + final SetupIntent.PaymentMethodOptions.Card card = paymentMethodOptions.getCard(); + if (card != null) { + additionalDataMap.put("payment_method_options_card_request_three_d_secure", card.getRequestThreeDSecure()); + } + // paymentMethodOptions also contains "sepa_debit" which contains "mandate_options" that currently has + // no properties, so it is ignored here (https://stripe.com/docs/api/setup_intents/object) + } + additionalDataMap.put("payment_method_types", stripeSetupIntent.getPaymentMethodTypes()); + additionalDataMap.put("single_use_mandate_id", stripeSetupIntent.getSingleUseMandate()); + additionalDataMap.put("status", stripeSetupIntent.getStatus()); + additionalDataMap.put("usage", stripeSetupIntent.getUsage()); + + return additionalDataMap; + } + public static Map toAdditionalDataMap(final Session session, @Nullable final String pk) { final Map additionalDataMap = new HashMap(); @@ -232,6 +288,7 @@ public static Map toAdditionalDataMap(final Session session, @Nu additionalDataMap.put("object", session.getObject()); additionalDataMap.put("payment_intent_id", session.getPaymentIntent()); additionalDataMap.put("payment_method_types", session.getPaymentMethodTypes()); + additionalDataMap.put("setup_intent_id", session.getSetupIntent()); additionalDataMap.put("subscription_id", session.getSubscription()); additionalDataMap.put("success_url", session.getSuccessUrl()); if (pk != null) { diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java b/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java index 8de49f5..9694ea8 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java @@ -21,6 +21,8 @@ import java.util.Properties; import java.util.UUID; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.killbill.billing.ObjectType; import org.killbill.billing.account.api.Account; import org.killbill.billing.catalog.api.Currency; @@ -57,6 +59,38 @@ public class TestBase { protected StripeConfigPropertiesConfigurationHandler stripeConfigPropertiesConfigurationHandler; protected StripeDao dao; + private static Account buildAccount(final Currency currency, final String country) { + return buildAccount(currency, UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString().substring(0, 16), country); + } + + // Currently needed as TestUtils.buildAccount does not create a valid e-mail address + private static Account buildAccount(final Currency currency, final String address1, final String address2, final String city, final String stateOrProvince, final String postalCode, final String country) { + Account account = (Account)Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(account.getExternalKey()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(account.getName()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(account.getFirstNameLength()).thenReturn(4); + Mockito.when(account.getEmail()).thenReturn(UUID.randomUUID().toString() + "@example.com"); + Mockito.when(account.getBillCycleDayLocal()).thenReturn(2); + Mockito.when(account.getCurrency()).thenReturn(currency); + Mockito.when(account.getPaymentMethodId()).thenReturn(UUID.randomUUID()); + Mockito.when(account.getTimeZone()).thenReturn(DateTimeZone.getDefault()); + Mockito.when(account.getLocale()).thenReturn("en-US"); + Mockito.when(account.getAddress1()).thenReturn(address1); + Mockito.when(account.getAddress2()).thenReturn(address2); + Mockito.when(account.getCompanyName()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(account.getCity()).thenReturn(city); + Mockito.when(account.getStateOrProvince()).thenReturn(stateOrProvince); + Mockito.when(account.getPostalCode()).thenReturn(postalCode); + Mockito.when(account.getCountry()).thenReturn(country); + Mockito.when(account.getPhone()).thenReturn(UUID.randomUUID().toString().substring(0, 25)); + Mockito.when(account.isMigrated()).thenReturn(true); + Mockito.when(account.getCreatedDate()).thenReturn(new DateTime(2016, 1, 22, 10, 56, 47, DateTimeZone.UTC)); + Mockito.when(account.getUpdatedDate()).thenReturn(new DateTime(2016, 1, 22, 10, 56, 48, DateTimeZone.UTC)); + return account; + } + + @BeforeMethod(groups = {"slow", "integration"}) public void setUp() throws Exception { EmbeddedDbHelper.instance().resetDB(); @@ -67,7 +101,7 @@ public void setUp() throws Exception { context = Mockito.mock(CallContext.class); Mockito.when(context.getTenantId()).thenReturn(UUID.randomUUID()); - account = TestUtils.buildAccount(DEFAULT_CURRENCY, DEFAULT_COUNTRY); + account = buildAccount(DEFAULT_CURRENCY, DEFAULT_COUNTRY); killbillApi = TestUtils.buildOSGIKillbillAPI(account); customFieldUserApi = Mockito.mock(CustomFieldUserApi.class); Mockito.when(killbillApi.getCustomFieldUserApi()).thenReturn(customFieldUserApi); diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java index ae76e14..b9067a1 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java @@ -28,6 +28,7 @@ import org.asynchttpclient.BoundRequestBuilder; import org.joda.time.Period; import org.killbill.billing.ObjectType; +import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.payment.api.Payment; import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.payment.api.PaymentMethodPlugin; @@ -57,7 +58,9 @@ import com.stripe.model.Customer; import com.stripe.model.PaymentIntent; import com.stripe.model.PaymentMethod; +import com.stripe.model.PaymentMethod.Card; import com.stripe.model.PaymentSource; +import com.stripe.model.SetupIntent; import com.stripe.model.Token; import com.stripe.net.RequestOptions; @@ -81,7 +84,7 @@ public void testLegacyTokensAndChargesAPI() throws PaymentPluginApiException, St final Map card = new HashMap<>(); card.put("number", "4242424242424242"); card.put("exp_month", 1); - card.put("exp_year", 2021); + card.put("exp_year", 2030); card.put("cvc", "314"); final Map params = new HashMap<>(); params.put("card", card); @@ -133,7 +136,7 @@ public void testLegacyTokensAndChargesAPICustomerCreatedOutsideOfKillBill() thro final Map card = new HashMap<>(); card.put("number", "4242424242424242"); card.put("exp_month", 1); - card.put("exp_year", 2021); + card.put("exp_year", 2030); card.put("cvc", "314"); final Map params = new HashMap<>(); params.put("card", card); @@ -594,11 +597,15 @@ public void testAuthorizationFailureOn3DSPurchase() throws PaymentPluginApiExcep } } - @Test(groups = "integration", enabled = false, description = "Manual test") + @Test(groups = "integration", enabled = true, description = "Manual test") public void testHPP() throws PaymentPluginApiException, StripeException, PaymentApiException { final UUID kbAccountId = account.getId(); + final List paymentMethodTypes = new ArrayList(); + paymentMethodTypes.add("card"); + paymentMethodTypes.add("sepa_debit"); + final ImmutableList customFields = ImmutableList.of(new PluginProperty("payment_method_types", paymentMethodTypes, false)); final HostedPaymentPageFormDescriptor hostedPaymentPageFormDescriptor = stripePaymentPluginApi.buildFormDescriptor(kbAccountId, - ImmutableList.of(), + customFields, ImmutableList.of(), context); final String sessionId = PluginProperties.findPluginPropertyValue("id", hostedPaymentPageFormDescriptor.getFormFields()); @@ -607,7 +614,7 @@ public void testHPP() throws PaymentPluginApiException, StripeException, Payment System.out.println("sessionId: " + sessionId); // Set a breakpoint here // Modify src/test/resources/index.html to use your Stripe public key ... - // ... then open the file in your browser and test with card 4242424242424242 + // ... then open the file in your browser and test either with card 4242424242424242 or with sepa debit DE89370400440532013000 System.out.flush(); // Still no payment method @@ -631,16 +638,19 @@ public void testHPP() throws PaymentPluginApiException, StripeException, Payment // Verify refresh is a no-op. This will also verify that the custom field was created assertEquals(stripePaymentPluginApi.getPaymentMethods(kbAccountId, true, ImmutableList.of(), context), paymentMethodsNoRefresh); - // Verify customer has no payment (voided) - final String paymentIntentId = PluginProperties.findPluginPropertyValue("payment_intent_id", hostedPaymentPageFormDescriptor.getFormFields()); - assertEquals(PaymentIntent.retrieve(paymentIntentId, stripePaymentPluginApi.buildRequestOptions(context)).getStatus(), "canceled"); + // Verify successful "setup intent" + final String setupIntentId = PluginProperties.findPluginPropertyValue("setup_intent_id", hostedPaymentPageFormDescriptor.getFormFields()); + assertEquals(SetupIntent.retrieve(setupIntentId, stripePaymentPluginApi.buildRequestOptions(context)).getStatus(), "succeeded"); - // Verify we can charge the card + // Verify we can charge the payment method paymentMethodPlugin = stripePaymentPluginApi.getPaymentMethodDetail(kbAccountId, paymentMethodsNoRefresh.get(0).getPaymentMethodId(), ImmutableList.of(), context); - final Payment payment = TestUtils.buildPayment(account.getId(), account.getPaymentMethodId(), account.getCurrency(), killbillApi); + final PluginProperty pmType = paymentMethodPlugin.getProperties().stream().filter(prop -> "type".equals(prop.getKey())).findFirst().orElse(null); + final Boolean isSepaDebit = pmType != null && "sepa_debit".equals(pmType.getValue()); + // sepa debit is only available for EUR so for testing we make sure to use EUR in that case + final Payment payment = TestUtils.buildPayment(account.getId(), account.getPaymentMethodId(), isSepaDebit ? Currency.EUR : account.getCurrency(), killbillApi); final PaymentTransaction purchaseTransaction = TestUtils.buildPaymentTransaction(payment, TransactionType.AUTHORIZE, BigDecimal.TEN, payment.getCurrency()); final PaymentTransactionInfoPlugin purchaseInfoPlugin = stripePaymentPluginApi.purchasePayment(account.getId(), payment.getId(), @@ -650,8 +660,11 @@ public void testHPP() throws PaymentPluginApiException, StripeException, Payment purchaseTransaction.getCurrency(), ImmutableList.of(), context); + // sepa debit transactions will be returned as "PENDING" (i.e. status='processing') by Stripe instead of "PROCESSED" (i.e. status='succeeded') + final PaymentPluginStatus targetStatus = isSepaDebit ? PaymentPluginStatus.PENDING : PaymentPluginStatus.PROCESSED; + TestUtils.updatePaymentTransaction(purchaseTransaction, purchaseInfoPlugin); - verifyPaymentTransactionInfoPlugin(payment, purchaseTransaction, purchaseInfoPlugin, PaymentPluginStatus.PROCESSED); + verifyPaymentTransactionInfoPlugin(payment, purchaseTransaction, purchaseInfoPlugin, targetStatus); } private void verifyPaymentTransactionInfoPlugin(final Payment payment, @@ -704,7 +717,7 @@ private void createStripeCustomerWithCreditCardAndSyncPaymentMethod() throws Str } private Customer createStripeCustomerWithCreditCard(final UUID kbAccountId) throws StripeException { - // Create new customer with VISA card + // Create new customer with VISA card, see: https://stripe.com/docs/testing Map customerParams = new HashMap(); customerParams.put("payment_method", "pm_card_visa"); final Customer customer = Customer.create(customerParams, stripePaymentPluginApi.buildRequestOptions(context)); @@ -736,7 +749,7 @@ private void createStripeCustomerWith3DSCreditCardAndSyncPaymentMethod() throws } private Customer createStripeCustomerWith3DSCreditCard(final UUID kbAccountId) throws StripeException { - // Create new customer with VISA card + // Create new customer with VISA card, see: https://stripe.com/docs/testing Map customerParams = new HashMap(); customerParams.put("payment_method", "pm_card_threeDSecure2Required"); final Customer customer = Customer.create(customerParams, stripePaymentPluginApi.buildRequestOptions(context)); @@ -780,6 +793,8 @@ private Customer createStripeCustomerWithBankAccountAndCreditCard(final UUID kbA // so I reverse-engineered the call that stripe.js makes... String bankAccount = null; try { + System.out.println(super.context.getTenantId()); + // make sure to have your public key included in src/test/resources/stripe.properties (see README.md) bankAccount = new StripeJsClient().createBankAccount(stripeConfigPropertiesConfigurationHandler.getConfigurable(super.context.getTenantId()).getPublicKey()); } catch (Exception e) { fail(e.getMessage()); From ee5397e8f22f6e97b275cb647ddb207bdaa11984 Mon Sep 17 00:00:00 2001 From: Soeren Kress Date: Thu, 1 Apr 2021 18:08:54 +0200 Subject: [PATCH 2/3] Set testHPP test back to being disabled, optimized creation of test accounts --- .../billing/plugin/stripe/TestBase.java | 35 ++----------------- .../stripe/TestStripePaymentPluginApi.java | 2 +- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java b/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java index 9694ea8..c1b9909 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestBase.java @@ -59,38 +59,6 @@ public class TestBase { protected StripeConfigPropertiesConfigurationHandler stripeConfigPropertiesConfigurationHandler; protected StripeDao dao; - private static Account buildAccount(final Currency currency, final String country) { - return buildAccount(currency, UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString().substring(0, 16), country); - } - - // Currently needed as TestUtils.buildAccount does not create a valid e-mail address - private static Account buildAccount(final Currency currency, final String address1, final String address2, final String city, final String stateOrProvince, final String postalCode, final String country) { - Account account = (Account)Mockito.mock(Account.class); - Mockito.when(account.getId()).thenReturn(UUID.randomUUID()); - Mockito.when(account.getExternalKey()).thenReturn(UUID.randomUUID().toString()); - Mockito.when(account.getName()).thenReturn(UUID.randomUUID().toString()); - Mockito.when(account.getFirstNameLength()).thenReturn(4); - Mockito.when(account.getEmail()).thenReturn(UUID.randomUUID().toString() + "@example.com"); - Mockito.when(account.getBillCycleDayLocal()).thenReturn(2); - Mockito.when(account.getCurrency()).thenReturn(currency); - Mockito.when(account.getPaymentMethodId()).thenReturn(UUID.randomUUID()); - Mockito.when(account.getTimeZone()).thenReturn(DateTimeZone.getDefault()); - Mockito.when(account.getLocale()).thenReturn("en-US"); - Mockito.when(account.getAddress1()).thenReturn(address1); - Mockito.when(account.getAddress2()).thenReturn(address2); - Mockito.when(account.getCompanyName()).thenReturn(UUID.randomUUID().toString()); - Mockito.when(account.getCity()).thenReturn(city); - Mockito.when(account.getStateOrProvince()).thenReturn(stateOrProvince); - Mockito.when(account.getPostalCode()).thenReturn(postalCode); - Mockito.when(account.getCountry()).thenReturn(country); - Mockito.when(account.getPhone()).thenReturn(UUID.randomUUID().toString().substring(0, 25)); - Mockito.when(account.isMigrated()).thenReturn(true); - Mockito.when(account.getCreatedDate()).thenReturn(new DateTime(2016, 1, 22, 10, 56, 47, DateTimeZone.UTC)); - Mockito.when(account.getUpdatedDate()).thenReturn(new DateTime(2016, 1, 22, 10, 56, 48, DateTimeZone.UTC)); - return account; - } - - @BeforeMethod(groups = {"slow", "integration"}) public void setUp() throws Exception { EmbeddedDbHelper.instance().resetDB(); @@ -101,7 +69,8 @@ public void setUp() throws Exception { context = Mockito.mock(CallContext.class); Mockito.when(context.getTenantId()).thenReturn(UUID.randomUUID()); - account = buildAccount(DEFAULT_CURRENCY, DEFAULT_COUNTRY); + account = TestUtils.buildAccount(DEFAULT_CURRENCY, DEFAULT_COUNTRY); + Mockito.when(account.getEmail()).thenReturn(UUID.randomUUID().toString() + "@example.com"); killbillApi = TestUtils.buildOSGIKillbillAPI(account); customFieldUserApi = Mockito.mock(CustomFieldUserApi.class); Mockito.when(killbillApi.getCustomFieldUserApi()).thenReturn(customFieldUserApi); diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java index b9067a1..d05600c 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java @@ -597,7 +597,7 @@ public void testAuthorizationFailureOn3DSPurchase() throws PaymentPluginApiExcep } } - @Test(groups = "integration", enabled = true, description = "Manual test") + @Test(groups = "integration", enabled = false, description = "Manual test") public void testHPP() throws PaymentPluginApiException, StripeException, PaymentApiException { final UUID kbAccountId = account.getId(); final List paymentMethodTypes = new ArrayList(); From c15c020ec9c396f3f7d6676eeee852c486b025a0 Mon Sep 17 00:00:00 2001 From: Soeren Kress Date: Thu, 1 Apr 2021 18:14:42 +0200 Subject: [PATCH 3/3] Removed superfluous debug output --- .../billing/plugin/stripe/TestStripePaymentPluginApi.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java index d05600c..27a27eb 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java @@ -793,7 +793,6 @@ private Customer createStripeCustomerWithBankAccountAndCreditCard(final UUID kbA // so I reverse-engineered the call that stripe.js makes... String bankAccount = null; try { - System.out.println(super.context.getTenantId()); // make sure to have your public key included in src/test/resources/stripe.properties (see README.md) bankAccount = new StripeJsClient().createBankAccount(stripeConfigPropertiesConfigurationHandler.getConfigurable(super.context.getTenantId()).getPublicKey()); } catch (Exception e) {