diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b8bcb0..07cc1d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,51 +5,19 @@ on: - pull_request - workflow_dispatch -env: - MAVEN_FLAGS: "-B --no-transfer-progress" - MAVEN_OPTS: "-Xmx2G -XX:+ExitOnOutOfMemoryError -Dmaven.wagon.rto=60000 -Dmaven.wagon.httpconnectionManager.ttlSeconds=25 -Dmaven.wagon.http.retryHandler.count=3" - jobs: - tests: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - java-version: - - 8 - - 11 - java-distribution: - - adopt - - zulu - profile: - - travis - - integration-mysql - - integration-postgresql - is-pr: - - ${{ contains(github.event_name, 'pull_request') }} - # PR don't have access to secrets - exclude: - - is-pr: true - profile: integration-mysql - - is-pr: true - profile: integration-postgresql - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Setup Java - uses: actions/setup-java@v2 - with: - distribution: ${{ matrix.java-distribution }} - java-version: ${{ matrix.java-version }} - - name: Configure Sonatype mirror - uses: s4u/maven-settings-action@v2.3.0 - # Go to Sonatype directly to avoid delay syncs (could get rid of this if actions/setup-java were to support mirrors). - with: - mirrors: '[{"id": "oss-releases", "name": "Sonatype releases", "mirrorOf": "*", "url": "https://oss.sonatype.org/content/repositories/releases/"}]' - - name: Tests - env: - MAVEN_PROFILE: ${{ matrix.profile }} - STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} - STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }} - run: | - mvn ${MAVEN_FLAGS} clean install -P${MAVEN_PROFILE} + ci: + uses: killbill/gh-actions-shared/.github/workflows/ci.yml@main + with: + test-profile-matrix: '[ "travis", "integration-mysql", "integration-postgresql" ]' + secrets: + extra-env: '{ "STRIPE_API_KEY": "${{ secrets.STRIPE_API_KEY }}", "STRIPE_PUBLIC_KEY": "${{ secrets.STRIPE_PUBLIC_KEY }}" }' + + integration_test: + uses: killbill/gh-actions-shared/.github/workflows/integration_test.yml@main + with: + plugin_name: 'stripe' + integration_tests_goal: 'test:plugins:stripe' + integration_tests_ref: 'refs/heads/work-for-release-0.23.x' + secrets: + extra-env: '{ "STRIPE_API_KEY": "${{ secrets.STRIPE_API_KEY }}", "STRIPE_PUBLIC_KEY": "${{ secrets.STRIPE_PUBLIC_KEY }}" }' \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index f8e563f..e3874fb 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -28,7 +28,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 0739cf0..6034bcf 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,18 @@ A full end-to-end integration demo is available [here](https://github.com/killbi ## Kill Bill compatibility -| Plugin version | Kill Bill version | Stripe version | -| -------------: | -----------------: | --------------------------------------------------------: | -| 1.x.y | 0.14.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | -| 3.x.y | 0.16.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | -| 4.x.y | 0.18.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | -| 5.x.y | 0.19.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | -| 6.x.y | 0.20.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | -| 7.0.y | 0.22.z | [2019-12-03](https://stripe.com/docs/upgrades#2019-12-03) | -| 7.1.y | 0.22.z | [2019-12-03](https://stripe.com/docs/upgrades#2019-12-03) | -| 7.2.y | 0.22.z | [2020-08-27](https://stripe.com/docs/upgrades#2020-08-27) | -| 7.3.y | 0.22.z | [2020-08-27](https://stripe.com/docs/upgrades#2020-08-27) | +| Plugin version | Kill Bill version | Stripe version | +|---------------:|------------------:| --------------------------------------------------------: | +| 1.x.y | 0.14.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | +| 3.x.y | 0.16.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | +| 4.x.y | 0.18.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | +| 5.x.y | 0.19.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | +| 6.x.y | 0.20.z | [2015-02-18](https://stripe.com/docs/upgrades#2015-02-18) | +| 7.0.y | 0.22.z | [2019-12-03](https://stripe.com/docs/upgrades#2019-12-03) | +| 7.1.y | 0.22.z | [2019-12-03](https://stripe.com/docs/upgrades#2019-12-03) | +| 7.2.y | 0.22.z | [2020-08-27](https://stripe.com/docs/upgrades#2020-08-27) | +| 7.3.y | 0.22.z | [2020-08-27](https://stripe.com/docs/upgrades#2020-08-27) | +| 8.0.y | 0.23.z | [2020-08-27](https://stripe.com/docs/upgrades#2020-08-27) | ### Release notes diff --git a/docker/docker-compose.ci.mysql.yml b/docker/docker-compose.ci.mysql.yml new file mode 100644 index 0000000..41e0923 --- /dev/null +++ b/docker/docker-compose.ci.mysql.yml @@ -0,0 +1,44 @@ +# +# Copyright 2020-2022 Equinix, Inc +# Copyright 2014-2022 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. +# + +version: '3.8' + +services: + killbill: + network_mode: host + image: killbill/killbill:0.23.0-9116fa4-SNAPSHOT + environment: + - KILLBILL_CATALOG_URI=SpyCarAdvanced.xml + - KILLBILL_DAO_URL=jdbc:mysql://127.0.0.1:3306/killbill + - KILLBILL_DAO_USER=root + - KILLBILL_DAO_PASSWORD=root + - KILLBILL_SERVER_TEST_MODE=true + - KILLBILL_INVOICE_SANITY_SAFETY_BOUND_ENABLED=false + - KILLBILL_INVOICE_MAX_DAILY_NUMBER_OF_ITEMS_SAFETY_BOUND=-1 + depends_on: + - db + volumes: + - /var/tmp/bundles/plugins/java/stripe-plugin:/var/lib/killbill/bundles/plugins/java/stripe-plugin + - /var/tmp/tomcat_logs:/var/lib/tomcat/logs + db: + network_mode: host + image: killbill/mariadb:0.23 + environment: + - MYSQL_ROOT_PASSWORD=root + toxiproxy: + network_mode: host + image: ghcr.io/shopify/toxiproxy diff --git a/docker/docker-compose.ci.postgresql.yml b/docker/docker-compose.ci.postgresql.yml new file mode 100644 index 0000000..2434ba7 --- /dev/null +++ b/docker/docker-compose.ci.postgresql.yml @@ -0,0 +1,44 @@ +# +# Copyright 2020-2022 Equinix, Inc +# Copyright 2014-2022 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. +# + +version: '3.8' + +services: + killbill: + network_mode: host + image: killbill/killbill:0.23.0-9116fa4-SNAPSHOT + environment: + - KILLBILL_CATALOG_URI=SpyCarAdvanced.xml + - KILLBILL_DAO_URL=jdbc:postgresql://127.0.0.1:5432/killbill + - KILLBILL_DAO_USER=postgres + - KILLBILL_DAO_PASSWORD=postgres + - KILLBILL_SERVER_TEST_MODE=true + - KILLBILL_INVOICE_SANITY_SAFETY_BOUND_ENABLED=false + - KILLBILL_INVOICE_MAX_DAILY_NUMBER_OF_ITEMS_SAFETY_BOUND=-1 + depends_on: + - db + volumes: + - /var/tmp/bundles/plugins/java/stripe-plugin:/var/lib/killbill/bundles/plugins/java/stripe-plugin + - /var/tmp/tomcat_logs:/var/lib/tomcat/logs + db: + network_mode: host + image: killbill/postgresql:0.23 + environment: + - POSTGRES_PASSWORD=postgres + toxiproxy: + network_mode: host + image: ghcr.io/shopify/toxiproxy diff --git a/pom.xml b/pom.xml index 331d5e4..6c1e777 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ + 9.4.46.v20220331 org.killbill.billing.plugin.stripe.* + + + + org.reactivestreams + reactive-streams + 1.0.4 + + + com.fasterxml.jackson.core @@ -57,6 +68,21 @@ com.fasterxml.jackson.core jackson-databind + + com.github.tomakehurst + wiremock + test + + + javax.xml.bind + jaxb-api + + + org.apache.commons + commons-lang3 + + + com.google.code.findbugs jsr305 @@ -72,27 +98,22 @@ com.stripe stripe-java - 20.33.0 + 21.15.0 - io.airlift - testing-mysql-server + io.zonky.test + embedded-postgres test - io.airlift - testing-postgresql-server - test + jakarta.servlet + jakarta.servlet-api + provided javax.inject javax.inject - - javax.servlet - javax.servlet-api - provided - joda-time joda-time @@ -103,10 +124,6 @@ org.apache.felix.framework provided - - org.asynchttpclient - async-http-client - org.jooby jooby @@ -114,8 +131,13 @@ org.jooq jooq - - 3.13.5 + + + + org.slf4j + slf4j-api + + org.kill-bill.billing @@ -173,6 +195,16 @@ killbill-embeddeddb-common test + + org.kill-bill.commons + killbill-metrics-api + provided + + + org.kill-bill.testing + testing-mysql-server + test + org.mockito mockito-core @@ -204,7 +236,7 @@ org.jooq jooq - 3.14.13 + 3.15.10 3.13.5 diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index bd9f29c..6271b04 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -19,4 +19,7 @@ + + + diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java index 65edaf1..936104c 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java @@ -16,6 +16,10 @@ */ package org.killbill.billing.plugin.stripe; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Proxy.Type; +import java.security.GeneralSecurityException; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; @@ -23,17 +27,29 @@ import java.util.Properties; import javax.annotation.Nullable; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import org.joda.time.Period; +import org.killbill.billing.plugin.util.http.SslUtils; import com.google.common.base.Ascii; import com.google.common.base.MoreObjects; import com.google.common.base.Strings; +import com.stripe.Stripe; +import com.stripe.net.RequestOptions; +import com.stripe.net.RequestOptions.RequestOptionsBuilder; public class StripeConfigProperties { private static final String PROPERTY_PREFIX = "org.killbill.billing.plugin.stripe."; + private static final SSLSocketFactory DEFAULT_SSL_SOCKET_FACTORY = HttpsURLConnection.getDefaultSSLSocketFactory(); + private static final HostnameVerifier DEFAULT_HOSTNAME_VERIFIER = HttpsURLConnection.getDefaultHostnameVerifier(); + private static final String DEFAULT_API_BASE = Stripe.getApiBase(); + public static final String DEFAULT_PENDING_PAYMENT_EXPIRATION_PERIOD = "P3d"; public static final String DEFAULT_PENDING_3DS_PAYMENT_EXPIRATION_PERIOD = "PT3h"; public static final String DEFAULT_PENDING_HPP_PAYMENT_WITHOUT_COMPLETION_EXPIRATION_PERIOD = "PT1h"; @@ -46,6 +62,9 @@ public class StripeConfigProperties { private final String region; private final String apiKey; private final String publicKey; + private final String apiBase; + private final String proxyHost; + private final int proxyPort; private final String connectionTimeout; private final String readTimeout; private final Period pendingPaymentExpirationPeriod; @@ -60,6 +79,9 @@ public StripeConfigProperties(final Properties properties, final String region) this.region = region; this.apiKey = properties.getProperty(PROPERTY_PREFIX + "apiKey"); this.publicKey = properties.getProperty(PROPERTY_PREFIX + "publicKey"); + this.apiBase = properties.getProperty(PROPERTY_PREFIX + "apiBase"); + this.proxyHost = properties.getProperty(PROPERTY_PREFIX + "proxyHost"); + this.proxyPort = Integer.parseInt(properties.getProperty(PROPERTY_PREFIX + "proxyPort", "-1")); this.connectionTimeout = properties.getProperty(PROPERTY_PREFIX + "connectionTimeout", DEFAULT_CONNECTION_TIMEOUT); this.readTimeout = properties.getProperty(PROPERTY_PREFIX + "readTimeout", DEFAULT_READ_TIMEOUT); this.pendingPaymentExpirationPeriod = readPendingExpirationProperty(properties); @@ -78,6 +100,18 @@ public String getPublicKey() { return publicKey; } + public String getApiBase() { + return apiBase; + } + + public String getProxyHost() { + return proxyHost; + } + + public int getProxyPort() { + return proxyPort; + } + public String getConnectionTimeout() { return connectionTimeout; } @@ -114,6 +148,35 @@ public Period getPendingHppPaymentWithoutCompletionExpirationPeriod() { return pendingHppPaymentWithoutCompletionExpirationPeriod; } + public RequestOptions toRequestOptions() { + if (getApiBase() != null) { + Stripe.overrideApiBase(getApiBase()); + + // Since this is for testing only, disable certificates verification + try { + final SSLContext sc = SslUtils.getInstance().getSSLContext(true); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); + } catch (final GeneralSecurityException e) { + throw new RuntimeException(e); + } + } else { + // Allow to switch back and forth (useful for tests) + Stripe.overrideApiBase(DEFAULT_API_BASE); + HttpsURLConnection.setDefaultSSLSocketFactory(DEFAULT_SSL_SOCKET_FACTORY); + HttpsURLConnection.setDefaultHostnameVerifier(DEFAULT_HOSTNAME_VERIFIER); + } + + final RequestOptionsBuilder requestOptionsBuilder = RequestOptions.builder() + .setConnectTimeout(Integer.parseInt(getConnectionTimeout())) + .setReadTimeout(Integer.parseInt(getReadTimeout())) + .setApiKey(getApiKey()); + if (getProxyHost() != null && getProxyPort() != -1) { + requestOptionsBuilder.setConnectionProxy(new Proxy(Type.HTTP, new InetSocketAddress(getProxyHost(), getProxyPort()))); + } + return requestOptionsBuilder.build(); + } + private Period readPendingExpirationProperty(final Properties properties) { final String pendingExpirationPeriods = properties.getProperty(PROPERTY_PREFIX + "pendingPaymentExpirationPeriod"); final Map paymentMethodToExpirationPeriodString = new HashMap(); diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeHealthcheck.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeHealthcheck.java index dfcfd12..40517b6 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripeHealthcheck.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeHealthcheck.java @@ -59,11 +59,7 @@ public HealthStatus getHealthStatus(@Nullable final Tenant tenant, @Nullable fin } private HealthStatus pingStripe(final StripeConfigProperties stripeConfigProperties) { - final RequestOptions requestOptions = RequestOptions.builder() - .setConnectTimeout(Integer.parseInt(stripeConfigProperties.getConnectionTimeout())) - .setReadTimeout(Integer.parseInt(stripeConfigProperties.getReadTimeout())) - .setApiKey(stripeConfigProperties.getApiKey()) - .build(); + final RequestOptions requestOptions = stripeConfigProperties.toRequestOptions(); // Found this endpoint by cURLing random urls - let's hope it's stable :-) final String url = String.format("%s%s", Stripe.getApiBase(), "/healthcheck"); @@ -87,6 +83,7 @@ private HealthStatus pingStripe(final StripeConfigProperties stripeConfigPropert } } + public static class StripeHealthcheckResponse extends HashMap implements StripeObjectInterface { private static final long serialVersionUID = 1L; 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 039a119..20ce4eb 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; @@ -73,6 +74,7 @@ import com.stripe.exception.CardException; import com.stripe.exception.StripeException; import com.stripe.model.Charge; +import com.stripe.model.ChargeSearchResult; import com.stripe.model.Customer; import com.stripe.model.HasId; import com.stripe.model.PaymentIntent; @@ -85,6 +87,7 @@ import com.stripe.model.Token; import com.stripe.model.checkout.Session; import com.stripe.net.RequestOptions; +import com.stripe.param.ChargeSearchParams; import com.stripe.param.PaymentIntentCancelParams; public class StripePaymentPluginApi extends PluginPaymentPluginApi { @@ -95,7 +98,7 @@ private enum CaptureMethod { public final String value; - CaptureMethod(String value) { + CaptureMethod(final String value) { this.value = value; } } @@ -109,7 +112,7 @@ private enum CaptureMethod { private final StripeConfigPropertiesConfigurationHandler stripeConfigPropertiesConfigurationHandler; private final StripeDao dao; - static final List metadataFilter = ImmutableList.of("payment_method_types"); + static final List metadataFilter = List.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 @@ -124,8 +127,8 @@ public StripePaymentPluginApi(final StripeConfigPropertiesConfigurationHandler s super(killbillAPI, configProperties, clock, dao); this.stripeConfigPropertiesConfigurationHandler = stripeConfigPropertiesConfigurationHandler; this.dao = dao; - expandSourcesParams = new HashMap(); - expandSourcesParams.put("expand", ImmutableList.of("sources")); + expandSourcesParams = new HashMap<>(); + expandSourcesParams.put("expand", List.of("sources")); } @@ -145,12 +148,10 @@ public List getPaymentInfo(final UUID kbAccountId, final StripePaymentTransactionInfoPlugin transactionToExpire = expiredPaymentPolicy.isExpired(transactions); if (transactionToExpire != null) { logger.info("Canceling expired Stripe transaction {} (created {})", transactionToExpire.getStripeResponseRecord().getStripeId(), transactionToExpire.getStripeResponseRecord().getCreatedDate()); - final Map additionalMetadata = ImmutableMap.builder() - .put(PROPERTY_OVERRIDDEN_TRANSACTION_STATUS, - PaymentPluginStatus.CANCELED.toString()) - .put("message", - "Payment Expired - Cancelled by Janitor") - .build(); + final Map additionalMetadata = Map.of(PROPERTY_OVERRIDDEN_TRANSACTION_STATUS, + PaymentPluginStatus.CANCELED.toString(), + "message", + "Payment Expired - Cancelled by Janitor"); try { dao.updateResponse(transactionToExpire.getStripeResponseRecord(), additionalMetadata); } catch (final SQLException e) { @@ -187,13 +188,47 @@ else if (stripeConfigPropertiesConfigurationHandler.getConfigurable(context.getT requestOptions ); } - dao.updateResponse(transaction.getKbTransactionPaymentId(), intent, context.getTenantId()); + final Charge lastCharge = getLastCharge(intent, Collections.emptyMap(), requestOptions); + dao.updateResponse(transaction.getKbTransactionPaymentId(), intent, lastCharge, context.getTenantId()); wasRefreshed = true; } catch (final StripeException e) { logger.warn("Unable to fetch latest payment state in Stripe, data might be stale", e); } catch (final SQLException e) { throw new PaymentPluginApiException("Unable to refresh payment", e); } + } else if (transaction.getStatus() == PaymentPluginStatus.UNDEFINED) { + final ChargeSearchParams searchParams = ChargeSearchParams.builder().setQuery("metadata['kbTransactionId']:'" + transaction.getKbTransactionPaymentId() + "'").build(); + try { + final ChargeSearchResult result = Charge.search(searchParams, requestOptions); + if (result.getData().size() == 1) { + final Charge charge = result.getData().get(0); + if (charge.getPaymentIntent() != null) { + final PaymentIntent intent = PaymentIntent.retrieve(charge.getPaymentIntent(), requestOptions); + logger.info("Fixing Stripe transaction {}", intent.getId()); + final Charge lastCharge = getLastCharge(intent, Collections.emptyMap(), requestOptions); + dao.updateResponse(transaction.getKbTransactionPaymentId(), intent, lastCharge, context.getTenantId()); + wasRefreshed = true; + } + } else if (result.getData().isEmpty()) { + logger.info("Canceling UNKNOWN Stripe transaction for kbTransactionId={}", transaction.getKbTransactionPaymentId()); + final Map additionalMetadata = ImmutableMap.builder() + .put(PROPERTY_OVERRIDDEN_TRANSACTION_STATUS, + PaymentPluginStatus.CANCELED.toString()) + .put("message", + "Payment didn't happen - Cancelled by Janitor") + .build(); + try { + dao.updateResponse(transaction.getKbTransactionPaymentId(), additionalMetadata, context.getTenantId()); + wasRefreshed = true; + } catch (final SQLException e) { + throw new PaymentPluginApiException("Unable to update expired payment", e); + } + } // Anything else, manual checks needed + } catch (final StripeException e) { + logger.warn("Unable to fetch latest payment state in Stripe, data might be stale", e); + } catch (final SQLException e) { + throw new PaymentPluginApiException("Unable to refresh payment", e); + } } } @@ -486,23 +521,23 @@ public List getPaymentMethods(final UUID kbAccountId, f final RequestOptions requestOptions = buildRequestOptions(context); // Track the objects (the various Stripe APIs can return the same objects under a different type) - final Set stripeObjectsTreated = new HashSet(); + final Set stripeObjectsTreated = new HashSet<>(); try { // Start with PaymentMethod... - final Map paymentMethodParams = new HashMap(); + final Map paymentMethodParams = new HashMap<>(); paymentMethodParams.put("customer", stripeCustomerId); paymentMethodParams.put("type", "card"); - final Iterable stripePaymentMethodsCard = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(); + final Iterable stripePaymentMethodsCard = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(paymentMethodParams, requestOptions); syncPaymentMethods(kbAccountId, stripePaymentMethodsCard, existingPaymentMethodByStripeId, stripeObjectsTreated, context); paymentMethodParams.put("type", "sepa_debit"); - final Iterable stripePaymentMethodsSepaDebit = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(); + final Iterable stripePaymentMethodsSepaDebit = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable(paymentMethodParams, requestOptions); syncPaymentMethods(kbAccountId, stripePaymentMethodsSepaDebit, existingPaymentMethodByStripeId, stripeObjectsTreated, context); // Then go through the sources final PaymentSourceCollection psc = Customer.retrieve(stripeCustomerId, expandSourcesParams, requestOptions).getSources(); if (psc != null) { - final Iterable stripeSources = psc.autoPagingIterable(); + final Iterable stripeSources = psc.autoPagingIterable(paymentMethodParams, requestOptions); syncPaymentMethods(kbAccountId, stripeSources, existingPaymentMethodByStripeId, stripeObjectsTreated, context); } } catch (final StripeException e) { @@ -709,11 +744,7 @@ public PaymentIntent execute(final Account account, final StripePaymentMethodsRe @VisibleForTesting RequestOptions buildRequestOptions(final TenantContext context) { final StripeConfigProperties stripeConfigProperties = stripeConfigPropertiesConfigurationHandler.getConfigurable(context.getTenantId()); - return RequestOptions.builder() - .setConnectTimeout(Integer.parseInt(stripeConfigProperties.getConnectionTimeout())) - .setReadTimeout(Integer.parseInt(stripeConfigProperties.getReadTimeout())) - .setApiKey(stripeConfigProperties.getApiKey()) - .build(); + return stripeConfigProperties.toRequestOptions(); } @Override @@ -826,7 +857,7 @@ public PaymentIntent execute(final Account account, final StripePaymentMethodsRe } paymentIntentParams.put("metadata", ImmutableMap.of("kbAccountId", kbAccountId, "kbPaymentId", kbPaymentId, - "kbTransactionId", kbTransactionId, + "kbTransactionId", kbTransactionId, // Used by the Janitor below "kbPaymentMethodId", kbPaymentMethodId)); final Map additionalData = StripeDao.fromAdditionalData(paymentMethodsRecord.getAdditionalData()); @@ -886,7 +917,9 @@ private PaymentTransactionInfoPlugin executeInitialTransaction(final Transaction final StripePaymentMethodsRecord nonNullPaymentMethodsRecord = getStripePaymentMethodsRecord(kbPaymentMethodId, context); final DateTime utcNow = clock.getUTCNow(); - PaymentIntent response; + PaymentIntent response = null; + StripeException stripeException = null; + final RequestOptions requestOptions = buildRequestOptions(context); if (shouldSkipStripe(properties)) { throw new UnsupportedOperationException("TODO"); } else { @@ -894,24 +927,26 @@ private PaymentTransactionInfoPlugin executeInitialTransaction(final Transaction response = transactionExecutor.execute(account, nonNullPaymentMethodsRecord); } catch (final CardException e) { try { - final RequestOptions requestOptions = buildRequestOptions(context); final Charge charge = Charge.retrieve(e.getCharge(), requestOptions); final String paymentIntentId = charge.getPaymentIntent(); final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, requestOptions); response = paymentIntent; } catch (final StripeException e2) { - throw new PaymentPluginApiException("Error getting card error details from Stripe", e2); + logger.warn("Error connecting to Stripe", e2); + stripeException = e2; } } catch (final StripeException e) { - throw new PaymentPluginApiException("Error connecting to Stripe", e); + logger.warn("Error connecting to Stripe", e); + stripeException = e; } } try { - final StripeResponsesRecord responsesRecord = dao.addResponse(kbAccountId, kbPaymentId, kbTransactionId, transactionType, amount, currency, response, utcNow, context.getTenantId()); + final Charge lastCharge = getLastCharge(response, Collections.emptyMap(), requestOptions); + final StripeResponsesRecord responsesRecord = dao.addResponse(kbAccountId, kbPaymentId, kbTransactionId, transactionType, amount, currency, response, lastCharge, stripeException, utcNow, context.getTenantId()); return StripePaymentTransactionInfoPlugin.build(responsesRecord); } catch (final SQLException e) { - throw new PaymentPluginApiException("Payment went through, but we encountered a database error. Payment details: " + response.toString(), e); + throw new PaymentPluginApiException("Payment went through, but we encountered a database error. Payment details: " + response, e); } } @@ -940,22 +975,25 @@ private PaymentTransactionInfoPlugin executeFollowUpTransaction(final Transactio final DateTime utcNow = clock.getUTCNow(); - final PaymentIntent response; + PaymentIntent response = null; + StripeException stripeException = null; if (shouldSkipStripe(properties)) { throw new UnsupportedOperationException("TODO"); } else { try { response = transactionExecutor.execute(account, nonNullPaymentMethodsRecord, previousResponse); } catch (final StripeException e) { - throw new PaymentPluginApiException("Error connecting to Stripe", e); + logger.warn("Error connecting to Stripe", e); + stripeException = e; } } try { - final StripeResponsesRecord responsesRecord = dao.addResponse(kbAccountId, kbPaymentId, kbTransactionId, transactionType, amount, currency, response, utcNow, context.getTenantId()); + final Charge lastCharge = getLastCharge(response, Collections.emptyMap(), buildRequestOptions(context)); + final StripeResponsesRecord responsesRecord = dao.addResponse(kbAccountId, kbPaymentId, kbTransactionId, transactionType, amount, currency, response, lastCharge, stripeException, utcNow, context.getTenantId()); return StripePaymentTransactionInfoPlugin.build(responsesRecord); } catch (final SQLException e) { - throw new PaymentPluginApiException("Payment went through, but we encountered a database error. Payment details: " + (response.toString()), e); + throw new PaymentPluginApiException("Payment went through, but we encountered a database error. Payment details: " + response, e); } } @@ -1005,4 +1043,19 @@ private boolean shouldSkipStripe(final Iterable properties) { return "true".equals(PluginProperties.findPluginPropertyValue("skipGw", properties)) || "true".equals(PluginProperties.findPluginPropertyValue("skip_gw", properties)); } + private Charge getLastCharge(@Nullable final PaymentIntent stripePaymentIntent, + final Map params, + final RequestOptions requestOptions) { + if (stripePaymentIntent == null || stripePaymentIntent.getCharges() == null) { + return null; + } + + Charge lastCharge = null; + for (final Charge charge : stripePaymentIntent.getCharges().autoPagingIterable(params, requestOptions)) { + if (lastCharge == null || lastCharge.getCreated() < charge.getCreated()) { + lastCharge = charge; + } + } + return lastCharge; + } } diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentTransactionInfoPlugin.java b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentTransactionInfoPlugin.java index 12a1282..8200fb1 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentTransactionInfoPlugin.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentTransactionInfoPlugin.java @@ -108,11 +108,17 @@ private static PaymentPluginStatus getPaymentPluginStatus(final Map additionalDa } private static String getGatewayError(final Map additionalData) { - return (String) additionalData.get("last_charge_failure_message"); + if (additionalData.containsKey("stripe_error_message")) { + return (String) additionalData.get("stripe_error_message"); + } else if (additionalData.containsKey("last_charge_failure_message")) { + return (String) additionalData.get("last_charge_failure_message"); + } else { + return (String) additionalData.get("message"); + } } private static String getGatewayErrorCode(final Map additionalData) { - return (String) additionalData.get("last_charge_failure_code"); + return (String) additionalData.getOrDefault("stripe_error_code", additionalData.get("last_charge_failure_code")); } private static String truncate(@Nullable final String string) { 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 2377d54..59cc017 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePluginProperties.java @@ -17,11 +17,20 @@ package org.killbill.billing.plugin.stripe; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; import java.util.HashMap; import java.util.Map; import javax.annotation.Nullable; +import org.killbill.billing.payment.plugin.api.PaymentPluginStatus; + +import com.google.common.base.Throwables; +import com.stripe.exception.StripeException; import com.stripe.model.BankAccount; import com.stripe.model.Charge; import com.stripe.model.PaymentIntent; @@ -35,6 +44,8 @@ import com.stripe.model.Token; import com.stripe.model.checkout.Session; +import static org.killbill.billing.plugin.stripe.StripePaymentPluginApi.PROPERTY_OVERRIDDEN_TRANSACTION_STATUS; + // Stripe .toJson() is definitively not GDPR-friendly... public abstract class StripePluginProperties { @@ -175,7 +186,61 @@ public static Map toAdditionalDataMap(final PaymentMethod stripe return additionalDataMap; } - public static Map toAdditionalDataMap(final PaymentIntent stripePaymentIntent) { + public static Map toAdditionalDataMap(final StripeException stripeException) { + final Map additionalDataMap = new HashMap(); + + if (stripeException.getStripeError() != null) { + // See StripePaymentTransactionInfoPlugin#getGatewayError + additionalDataMap.put("stripe_error_message", stripeException.getStripeError().getMessage()); + // See StripePaymentTransactionInfoPlugin#getGatewayErrorCode + additionalDataMap.put("stripe_error_code", stripeException.getStripeError().getCode()); + } + additionalDataMap.put("code", stripeException.getCode()); + additionalDataMap.put("request_id", stripeException.getRequestId()); + additionalDataMap.put("status_code", stripeException.getStatusCode()); + additionalDataMap.put("message", stripeException.getMessage()); + + additionalDataMap.put(PROPERTY_OVERRIDDEN_TRANSACTION_STATUS, mapExceptionToCallResult(stripeException).toString()); + + return additionalDataMap; + } + + + /** + * Educated guess approach to transform exceptions into error status codes. + */ + private static PaymentPluginStatus mapExceptionToCallResult(final Exception e) { + //noinspection ThrowableResultOfMethodCallIgnored + final Throwable rootCause = Throwables.getRootCause(e); + final String errorMessage = rootCause.getMessage(); + if (rootCause instanceof ConnectException) { + return PaymentPluginStatus.CANCELED; + } else if (rootCause instanceof SocketTimeoutException) { + // read timeout + if (errorMessage.contains("Read timed out")) { + return PaymentPluginStatus.UNDEFINED; + } else if (errorMessage.contains("Unexpected end of file from server")) { + return PaymentPluginStatus.UNDEFINED; + } + } else if (rootCause instanceof SocketException) { + if (errorMessage.contains("Unexpected end of file from server")) { + return PaymentPluginStatus.UNDEFINED; + } + } else if (rootCause instanceof UnknownHostException) { + return PaymentPluginStatus.CANCELED; + } else if (rootCause instanceof IOException) { + if (errorMessage.contains("Invalid Http response")) { + // unparsable data as response + return PaymentPluginStatus.UNDEFINED; + } else if (errorMessage.contains("Bogus chunk size")) { + return PaymentPluginStatus.UNDEFINED; + } + } + + return PaymentPluginStatus.UNDEFINED; + } + + public static Map toAdditionalDataMap(final PaymentIntent stripePaymentIntent, @Nullable final Charge lastCharge) { final Map additionalDataMap = new HashMap(); additionalDataMap.put("amount", stripePaymentIntent.getAmount()); @@ -186,36 +251,6 @@ public static Map toAdditionalDataMap(final PaymentIntent stripe additionalDataMap.put("canceled_at", stripePaymentIntent.getCanceledAt()); additionalDataMap.put("cancellation_reason", stripePaymentIntent.getCancellationReason()); additionalDataMap.put("capture_method", stripePaymentIntent.getCaptureMethod()); - if (stripePaymentIntent.getCharges() != null) { - Charge lastCharge = null; - for (final Charge charge : stripePaymentIntent.getCharges().autoPagingIterable()) { - if (lastCharge == null || lastCharge.getCreated() < charge.getCreated()) { - lastCharge = charge; - } - } - if (lastCharge != null) { - // Keep the state for the last charge (maps to our payment transaction) - additionalDataMap.put("last_charge_amount", lastCharge.getAmount()); - additionalDataMap.put("last_charge_authorization_code", lastCharge.getAuthorizationCode()); - additionalDataMap.put("last_charge_balance_transaction_id", lastCharge.getBalanceTransaction()); - additionalDataMap.put("last_charge_created", lastCharge.getCreated()); - additionalDataMap.put("last_charge_currency", lastCharge.getCurrency()); - additionalDataMap.put("last_charge_description", lastCharge.getDescription()); - additionalDataMap.put("last_charge_failure_code", lastCharge.getFailureCode()); - additionalDataMap.put("last_charge_failure_message", lastCharge.getFailureMessage()); - additionalDataMap.put("last_charge_id", lastCharge.getId()); - additionalDataMap.put("last_charge_metadata", lastCharge.getMetadata()); - additionalDataMap.put("last_charge_object", lastCharge.getObject()); - additionalDataMap.put("last_charge_outcome", lastCharge.getOutcome()); - additionalDataMap.put("last_charge_paid", lastCharge.getPaid()); - additionalDataMap.put("last_charge_payment_method_id", lastCharge.getPaymentMethod()); - if (lastCharge.getPaymentMethodDetails() != null) { - additionalDataMap.put("last_charge_payment_method_type", lastCharge.getPaymentMethodDetails().getType()); - } - additionalDataMap.put("last_charge_statement_descriptor", lastCharge.getStatementDescriptor()); - additionalDataMap.put("last_charge_status", lastCharge.getStatus()); - } - } additionalDataMap.put("confirmation_method", stripePaymentIntent.getConfirmationMethod()); additionalDataMap.put("created", stripePaymentIntent.getCreated()); additionalDataMap.put("currency", stripePaymentIntent.getCurrency()); @@ -236,6 +271,29 @@ public static Map toAdditionalDataMap(final PaymentIntent stripe additionalDataMap.put("status", stripePaymentIntent.getStatus()); additionalDataMap.put("transfer_group", stripePaymentIntent.getTransferGroup()); + if (lastCharge != null) { + // Keep the state for the last charge (maps to our payment transaction) + additionalDataMap.put("last_charge_amount", lastCharge.getAmount()); + additionalDataMap.put("last_charge_authorization_code", lastCharge.getAuthorizationCode()); + additionalDataMap.put("last_charge_balance_transaction_id", lastCharge.getBalanceTransaction()); + additionalDataMap.put("last_charge_created", lastCharge.getCreated()); + additionalDataMap.put("last_charge_currency", lastCharge.getCurrency()); + additionalDataMap.put("last_charge_description", lastCharge.getDescription()); + additionalDataMap.put("last_charge_failure_code", lastCharge.getFailureCode()); + additionalDataMap.put("last_charge_failure_message", lastCharge.getFailureMessage()); + additionalDataMap.put("last_charge_id", lastCharge.getId()); + additionalDataMap.put("last_charge_metadata", lastCharge.getMetadata()); + additionalDataMap.put("last_charge_object", lastCharge.getObject()); + additionalDataMap.put("last_charge_outcome", lastCharge.getOutcome()); + additionalDataMap.put("last_charge_paid", lastCharge.getPaid()); + additionalDataMap.put("last_charge_payment_method_id", lastCharge.getPaymentMethod()); + if (lastCharge.getPaymentMethodDetails() != null) { + additionalDataMap.put("last_charge_payment_method_type", lastCharge.getPaymentMethodDetails().getType()); + } + additionalDataMap.put("last_charge_statement_descriptor", lastCharge.getStatementDescriptor()); + additionalDataMap.put("last_charge_status", lastCharge.getStatus()); + } + return additionalDataMap; } diff --git a/src/main/java/org/killbill/billing/plugin/stripe/dao/StripeDao.java b/src/main/java/org/killbill/billing/plugin/stripe/dao/StripeDao.java index 65a8014..4dde585 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/dao/StripeDao.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/dao/StripeDao.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.sql.Connection; import java.sql.SQLException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -29,6 +30,7 @@ import javax.sql.DataSource; import org.joda.time.DateTime; +import org.jooq.DSLContext; import org.jooq.impl.DSL; import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.payment.api.PluginProperty; @@ -37,13 +39,16 @@ import org.killbill.billing.plugin.dao.payment.PluginPaymentDao; import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.google.common.collect.ImmutableMap; + import org.killbill.billing.plugin.stripe.StripePluginProperties; import org.killbill.billing.plugin.stripe.dao.gen.tables.StripePaymentMethods; import org.killbill.billing.plugin.stripe.dao.gen.tables.StripeResponses; import org.killbill.billing.plugin.stripe.dao.gen.tables.records.StripeHppRequestsRecord; import org.killbill.billing.plugin.stripe.dao.gen.tables.records.StripePaymentMethodsRecord; import org.killbill.billing.plugin.stripe.dao.gen.tables.records.StripeResponsesRecord; + +import com.stripe.exception.StripeException; +import com.stripe.model.Charge; import com.stripe.model.PaymentIntent; import com.stripe.model.checkout.Session; @@ -180,47 +185,56 @@ public StripeResponsesRecord addResponse(final UUID kbAccountId, final TransactionType transactionType, final BigDecimal amount, final Currency currency, - final PaymentIntent stripePaymentIntent, + @Nullable final PaymentIntent stripePaymentIntent, + @Nullable final Charge lastCharge, + @Nullable final StripeException stripeException, final DateTime utcNow, final UUID kbTenantId) throws SQLException { - final Map additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripePaymentIntent); + final Map additionalDataMap; + if (stripePaymentIntent != null) { + additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripePaymentIntent, lastCharge); + } else if (stripeException != null) { + additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripeException); + } else { + additionalDataMap = Collections.emptyMap(); + } return execute(dataSource.getConnection(), - new WithConnectionCallback() { - @Override - public StripeResponsesRecord withConnection(final Connection conn) throws SQLException { - return DSL.using(conn, dialect, settings) - .insertInto(STRIPE_RESPONSES, - STRIPE_RESPONSES.KB_ACCOUNT_ID, - STRIPE_RESPONSES.KB_PAYMENT_ID, - STRIPE_RESPONSES.KB_PAYMENT_TRANSACTION_ID, - STRIPE_RESPONSES.TRANSACTION_TYPE, - STRIPE_RESPONSES.AMOUNT, - STRIPE_RESPONSES.CURRENCY, - STRIPE_RESPONSES.STRIPE_ID, - STRIPE_RESPONSES.ADDITIONAL_DATA, - STRIPE_RESPONSES.CREATED_DATE, - STRIPE_RESPONSES.KB_TENANT_ID) - .values(kbAccountId.toString(), - kbPaymentId.toString(), - kbPaymentTransactionId.toString(), - transactionType.toString(), - amount, - currency == null ? null : currency.name(), - stripePaymentIntent.getId(), - asString(additionalDataMap), - toLocalDateTime(utcNow), - kbTenantId.toString()) - .returning() - .fetchOne(); - } - }); + conn -> DSL.using(conn, dialect, settings).transactionResult(configuration -> { + final DSLContext dslContext = DSL.using(configuration); + dslContext.insertInto(STRIPE_RESPONSES, + STRIPE_RESPONSES.KB_ACCOUNT_ID, + STRIPE_RESPONSES.KB_PAYMENT_ID, + STRIPE_RESPONSES.KB_PAYMENT_TRANSACTION_ID, + STRIPE_RESPONSES.TRANSACTION_TYPE, + STRIPE_RESPONSES.AMOUNT, + STRIPE_RESPONSES.CURRENCY, + STRIPE_RESPONSES.STRIPE_ID, + STRIPE_RESPONSES.ADDITIONAL_DATA, + STRIPE_RESPONSES.CREATED_DATE, + STRIPE_RESPONSES.KB_TENANT_ID) + .values(kbAccountId.toString(), + kbPaymentId.toString(), + kbPaymentTransactionId.toString(), + transactionType.toString(), + amount, + currency == null ? null : currency.name(), + stripePaymentIntent == null ? null : stripePaymentIntent.getId(), + asString(additionalDataMap), + toLocalDateTime(utcNow), + kbTenantId.toString()) + .execute(); + return dslContext.fetchOne( + STRIPE_RESPONSES, + STRIPE_RESPONSES.RECORD_ID.eq(STRIPE_RESPONSES.RECORD_ID.getDataType().convert(dslContext.lastID()))); + })); } public StripeResponsesRecord updateResponse(final UUID kbPaymentTransactionId, final PaymentIntent stripePaymentIntent, + @Nullable final Charge lastCharge, final UUID kbTenantId) throws SQLException { - final Map additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripePaymentIntent); + final Map additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripePaymentIntent, lastCharge); return updateResponse(kbPaymentTransactionId, additionalDataMap, kbTenantId); } @@ -305,7 +319,7 @@ public StripeResponsesRecord withConnection(final Connection conn) throws SQLExc public static Map fromAdditionalData(@Nullable final String additionalData) { if (additionalData == null) { - return ImmutableMap.of(); + return Collections.emptyMap(); } try { diff --git a/src/main/resources/ddl.sql b/src/main/resources/ddl.sql index d684c9b..3962a38 100644 --- a/src/main/resources/ddl.sql +++ b/src/main/resources/ddl.sql @@ -40,7 +40,7 @@ create table stripe_responses ( , transaction_type varchar(32) not null , amount numeric(15,9) , currency char(3) -, stripe_id varchar(255) not null +, stripe_id varchar(255) default null , additional_data longtext default null , created_date datetime not null , kb_tenant_id char(36) not null 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 6d756f9..3303b30 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java @@ -18,6 +18,8 @@ package org.killbill.billing.plugin.stripe; import java.math.BigDecimal; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.HashMap; @@ -27,7 +29,6 @@ import java.util.stream.Collector; import java.util.stream.Collectors; -import org.asynchttpclient.BoundRequestBuilder; import org.joda.time.Period; import org.killbill.billing.ObjectType; import org.killbill.billing.catalog.api.Currency; @@ -930,7 +931,7 @@ private Customer createStripeCustomerWithBankAccountAndCreditCard(final UUID kbA amounts.add(32); amounts.add(45); params.put("amounts", amounts); - for (final PaymentSource source : customer.getSources().autoPagingIterable()) { + for (final PaymentSource source : customer.getSources().autoPagingIterable(params, options)) { if (source instanceof BankAccount) { ((BankAccount) source).verify(params, options); break; @@ -965,7 +966,7 @@ public String createBankAccount(String stripePublishableKey) throws Exception { final String body = "bank_account[account_number]=" + accountNumber + "&bank_account[country]=" + country + "&bank_account[currency]=" + currency + "&bank_account[routing_number]=" + routingNumber + "&bank_account[account_holder_name]=" + name + "&bank_account[account_holder_type]=" + type + "&key=" + stripePublishableKey; - final BoundRequestBuilder builder = getBuilderWithHeaderAndQuery(POST, url, ImmutableMap.of(), ImmutableMap.of()).setBody(body); + final HttpRequest.Builder builder = getBuilderWithHeaderAndQuery(POST, url, ImmutableMap.of(), ImmutableMap.of()).method(POST, BodyPublishers.ofString(body)); final Map response = executeAndWait(builder, DEFAULT_HTTP_TIMEOUT_SEC, Map.class, ResponseFormat.JSON); return (String) response.get("id"); diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApiWithErrors.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApiWithErrors.java new file mode 100644 index 0000000..442c42e --- /dev/null +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApiWithErrors.java @@ -0,0 +1,190 @@ +/* + * Copyright 2020-2022 Equinix, Inc + * Copyright 2014-2022 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.plugin.stripe; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.ServerSocket; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +import org.killbill.billing.catalog.api.Currency; +import org.killbill.billing.payment.api.Payment; +import org.killbill.billing.payment.api.PaymentTransaction; +import org.killbill.billing.payment.api.PluginProperty; +import org.killbill.billing.payment.api.TransactionType; +import org.killbill.billing.payment.plugin.api.PaymentPluginApiException; +import org.killbill.billing.payment.plugin.api.PaymentPluginStatus; +import org.killbill.billing.payment.plugin.api.PaymentTransactionInfoPlugin; +import org.killbill.billing.plugin.TestUtils; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.google.common.collect.ImmutableList; + +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.serverError; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Checks if the plugin could handle technical communication errors (strange responses, read/connect timeouts etc...) and map them to the correct PaymentPluginStatus. + *

+ * WireMock is used to create failure scenarios (toxiproxy will be used in the ruby ITs). + *

+ * Attention: If you have failing tests check first that you don't have a proxy configured (Charles, Fiddler, Burp etc...). + */ +public class TestStripePaymentPluginApiWithErrors extends TestBase { + + @BeforeMethod(groups = "slow") + public void setupConfig() throws Exception { + final Properties properties = new Properties(); + properties.put("org.killbill.billing.plugin.stripe.apiKey", "unused"); + properties.put("org.killbill.billing.plugin.stripe.apiBase", WireMockHelper.wireMockUri("/")); + + final StripeConfigProperties stripeConfigProperties = new StripeConfigProperties(properties, ""); + stripeConfigPropertiesConfigurationHandler.setDefaultConfigurable(stripeConfigProperties); + } + + @Test(groups = "slow") + public void testPurchaseConnectionException() throws Exception { + final UUID kbPaymentMethodId = UUID.randomUUID(); + dao.addPaymentMethod(account.getId(), kbPaymentMethodId, Collections.emptyMap(), "token", clock.getUTCNow(), context.getTenantId()); + + final Payment payment = TestUtils.buildPayment(account.getId(), kbPaymentMethodId, Currency.EUR, killbillApi); + final PaymentTransaction purchaseTransaction = TestUtils.buildPaymentTransaction(payment, TransactionType.PURCHASE, BigDecimal.TEN, payment.getCurrency()); + final PaymentTransactionInfoPlugin result = stripePaymentPluginApi.purchasePayment(account.getId(), + payment.getId(), + purchaseTransaction.getId(), + kbPaymentMethodId, + purchaseTransaction.getAmount(), + purchaseTransaction.getCurrency(), + ImmutableList.of(), + context); + + assertEquals(result.getStatus(), PaymentPluginStatus.CANCELED); + assertTrue(result.getGatewayError().startsWith("IOException during API request to Stripe (http://localhost:")); + assertNull(result.getGatewayErrorCode()); + + final List results = stripePaymentPluginApi.getPaymentInfo(account.getId(), payment.getId(), ImmutableList.of(), context); + assertEquals(results.size(), 1); + assertEquals(results.get(0).getStatus(), PaymentPluginStatus.CANCELED); + assertTrue(results.get(0).getGatewayError().startsWith("IOException during API request to Stripe (http://localhost:")); + assertNull(results.get(0).getGatewayErrorCode()); + } + + @Test(groups = "slow") + public void testPurchase500Error() throws Exception { + final UUID kbPaymentMethodId = UUID.randomUUID(); + dao.addPaymentMethod(account.getId(), kbPaymentMethodId, Collections.emptyMap(), "token", clock.getUTCNow(), context.getTenantId()); + + final Payment payment = TestUtils.buildPayment(account.getId(), kbPaymentMethodId, Currency.EUR, killbillApi); + final PaymentTransaction purchaseTransaction = TestUtils.buildPaymentTransaction(payment, TransactionType.PURCHASE, BigDecimal.TEN, payment.getCurrency()); + + final PaymentTransactionInfoPlugin result = WireMockHelper.doWithWireMock(new WithWireMock<>() { + @Override + public PaymentTransactionInfoPlugin execute(final WireMockServer server) throws PaymentPluginApiException { + stubFor(any(anyUrl()).willReturn(serverError().withBody("{\n" + + " \"error\": {\n" + + " \"code\": \"parameter_unknown\",\n" + + " \"doc_url\": \"https://stripe.com/docs/error-codes/parameter-unknown\",\n" + + " \"message\": \"Received unknown parameter: foo\",\n" + + " \"param\": \"foo\",\n" + + " \"type\": \"invalid_request_error\"\n" + + " }\n" + + "}"))); + + return stripePaymentPluginApi.purchasePayment(account.getId(), + payment.getId(), + purchaseTransaction.getId(), + kbPaymentMethodId, + purchaseTransaction.getAmount(), + purchaseTransaction.getCurrency(), + ImmutableList.of(), + context); + } + }); + + assertEquals(result.getStatus(), PaymentPluginStatus.UNDEFINED); + assertEquals(result.getGatewayError(), "Received unknown parameter: foo"); + assertEquals(result.getGatewayErrorCode(), "parameter_unknown"); + + final List results = stripePaymentPluginApi.getPaymentInfo(account.getId(), payment.getId(), ImmutableList.of(), context); + assertEquals(results.size(), 1); + assertEquals(results.get(0).getStatus(), PaymentPluginStatus.UNDEFINED); + assertEquals(results.get(0).getGatewayError(), "Received unknown parameter: foo"); + assertEquals(results.get(0).getGatewayErrorCode(), "parameter_unknown"); + } + + private interface WithWireMock { + + T execute(WireMockServer server) throws Exception; + } + + static class WireMockHelper { + + private static final WireMockHelper INSTANCE = new WireMockHelper(); + + public static WireMockHelper instance() { + return INSTANCE; + } + + private int freePort = -1; + + private synchronized int getFreePort() throws IOException { + if (freePort == -1) { + freePort = findFreePort(); + } + return freePort; + } + + public static String wireMockUri(final String path) throws IOException { + return "http://localhost:" + WireMockHelper.instance().getFreePort() + path; + } + + public static T doWithWireMock(final WithWireMock command) throws Exception { + final int wireMockPort = WireMockHelper.instance().getFreePort(); + final WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().port(wireMockPort)); + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockPort); + try { + return command.execute(wireMockServer); + } finally { + wireMockServer.shutdown(); + while (wireMockServer.isRunning()) { + Thread.sleep(1); + } + } + } + } + + static int findFreePort() throws IOException { + final ServerSocket serverSocket = new ServerSocket(0); + final int freePort = serverSocket.getLocalPort(); + serverSocket.close(); + return freePort; + } +}