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 @@
-
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 21dfd4c..c1d5038 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -13,5 +13,5 @@
-
+
\ 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 extends HasId> stripeSources = psc.autoPagingIterable();
+ final Iterable extends HasId> 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;
+ }
+}