Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SEPA direct debit payment method #38

Merged
merged 3 commits into from
Apr 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,15 @@ curl -v \
-H "X-Killbill-Comment: demo" \
"http://127.0.0.1:8080/plugins/killbill-stripe/checkout?kbAccountId=<KB_ACCOUNT_ID>"
```

The default is to only allow credit cards. If you want to enable sepa direct debit payments, you need to include the `paymentMethodTypes` option, i.e. change the URL of your POST request
to `http://127.0.0.1:8080/plugins/killbill-stripe/checkout?kbAccountId=<KB_ACCOUNT_ID>&paymentMethodTypes=card&paymentMethodTypes=sepa_debit`.

3. Redirect the user to the Stripe checkout page. The `sessionId` is returned as part of the `formFields` (`id` key):
```javascript
stripe.redirectToCheckout({ sessionId: 'cs_test_XXX' });
```
4. After entering the credit card, a $1 authorization will be triggered. Call `addPaymentMethod` to create the Stripe payment method and pass the `sessionId` in the plugin properties. This will void the authorization (if successful) and store the payment method in Kill Bill:
4. After entering the credit card or bank account details, the payment method will be available in Stripe. Call `addPaymentMethod` to store the payment method in Kill Bill:
```bash
curl -v \
-X POST \
Expand Down Expand Up @@ -158,6 +162,15 @@ curl -v \
-H "X-Killbill-Comment: demo" \
"http://127.0.0.1:8080/1.0/kb/accounts/<ACCOUNT_ID>/paymentMethods/refresh"
```
## Development

For testing you need to add your Stripe public and private key to `src/test/resources/stripe.properties`:

```
org.killbill.billing.plugin.stripe.apiKey=sk_test_XXX
org.killbill.billing.plugin.stripe.publicKey=pk_test_XXX
```

## About

Kill Bill is the leading Open-Source Subscription Billing & Payments Platform. For more information about the project, go to https://killbill.io/.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.killbill.billing.plugin.stripe;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand Down Expand Up @@ -62,18 +63,16 @@ public StripeCheckoutServlet(final OSGIKillbillClock clock,
public Result createSession(@Named("kbAccountId") final UUID kbAccountId,
@Named("successUrl") final Optional<String> successUrl,
@Named("cancelUrl") final Optional<String> cancelUrl,
@Named("lineItemName") final Optional<String> lineItemName,
@Named("kbInvoiceId") final Optional<String> kbInvoiceId,
@Named("lineItemAmount") final Optional<Integer> lineItemAmount,
@Named("paymentMethodTypes") final Optional<List<String>> paymentMethodTypes,
@Local @Named("killbill_tenant") final Tenant tenant) throws JsonProcessingException, PaymentPluginApiException {
final CallContext context = new PluginCallContext(StripeActivator.PLUGIN_NAME, clock.getClock().getUTCNow(), kbAccountId, tenant.getId());
final ImmutableList<PluginProperty> customFields = ImmutableList.of(
new PluginProperty("kb_account_id", kbAccountId.toString(), false),
new PluginProperty("kb_invoice_id", kbInvoiceId.orElse(null), false),
new PluginProperty("success_url", successUrl.orElse("https://example.com/success"), false),
new PluginProperty("success_url", successUrl.orElse("https://example.com/success?sessionId={CHECKOUT_SESSION_ID}"), false),
new PluginProperty("cancel_url", cancelUrl.orElse("https://example.com/cancel"), false),
new PluginProperty("line_item_name", lineItemName.orElse("Authorization charge"), false),
new PluginProperty("line_item_amount", lineItemAmount.orElse(100), false));
new PluginProperty("payment_method_types", paymentMethodTypes.orElse(null), false));
final HostedPaymentPageFormDescriptor hostedPaymentPageFormDescriptor = stripePaymentPluginApi.buildFormDescriptor(kbAccountId,
customFields,
ImmutableList.of(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
import com.stripe.model.PaymentSource;
import com.stripe.model.PaymentSourceCollection;
import com.stripe.model.Refund;
import com.stripe.model.SetupIntent;
import com.stripe.model.Source;
import com.stripe.model.Token;
import com.stripe.model.checkout.Session;
Expand All @@ -87,6 +88,17 @@

public class StripePaymentPluginApi extends PluginPaymentPluginApi<StripeResponsesRecord, StripeResponses, StripePaymentMethodsRecord, StripePaymentMethods> {

private enum CaptureMethod {
AUTOMATIC("automatic"),
MANUAL("manual");

public final String value;

CaptureMethod(String value) {
this.value = value;
}
}

private static final Logger logger = LoggerFactory.getLogger(StripePaymentPluginApi.class);

public static final String PROPERTY_FROM_HPP = "fromHPP";
Expand All @@ -95,12 +107,8 @@ public class StripePaymentPluginApi extends PluginPaymentPluginApi<StripeRespons

private final StripeConfigPropertiesConfigurationHandler stripeConfigPropertiesConfigurationHandler;
private final StripeDao dao;

static final List<String> metadataFilter = ImmutableList.of(
"line_item_name",
"line_item_amount",
"line_item_currency",
"line_item_quantity");

static final List<String> metadataFilter = ImmutableList.of("payment_method_types");

// needed for API calls to expand the response to contain the 'Sources'
// https://stripe.com/docs/api/expanding_objects?lang=java
Expand Down Expand Up @@ -252,31 +260,27 @@ public void addPaymentMethod(final UUID kbAccountId, final UUID kbPaymentMethodI
throw new PaymentPluginApiException("INTERNAL", "Unable to add payment method: missing StripeHppRequestsRecord for sessionId " + sessionId);
}

final String paymentIntentId = (String) StripeDao.fromAdditionalData(hppRecord.getAdditionalData()).get("payment_intent_id");
final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, requestOptions);
if ("requires_capture".equals(paymentIntent.getStatus())) {
// Void it
logger.info("Voiding Stripe transaction {}", paymentIntent.getId());
paymentIntent.cancel(requestOptions);

final String setupIntentId = (String) StripeDao.fromAdditionalData(hppRecord.getAdditionalData()).get("setup_intent_id");
final SetupIntent setupIntent = SetupIntent.retrieve(setupIntentId, requestOptions);
if ("succeeded".equals(setupIntent.getStatus())) {
final String existingCustomerId = getCustomerIdNoException(kbAccountId, context);
if (existingCustomerId == null) {
// Add magic custom field
logger.info("Mapping kbAccountId {} to Stripe customer {}", kbAccountId, paymentIntent.getCustomer());
logger.info("Mapping kbAccountId {} to Stripe customer {}", kbAccountId, setupIntent.getCustomer());
killbillAPI.getCustomFieldUserApi().addCustomFields(ImmutableList.of(new PluginCustomField(kbAccountId,
ObjectType.ACCOUNT,
"STRIPE_CUSTOMER_ID",
paymentIntent.getCustomer(),
setupIntent.getCustomer(),
clock.getUTCNow())), context);
} else if (!existingCustomerId.equals(paymentIntent.getCustomer())) {
throw new PaymentPluginApiException("USER", "Unable to add payment method : paymentIntent customerId is " + paymentIntent.getCustomer() + " but account already mapped to " + existingCustomerId);
} else if (!existingCustomerId.equals(setupIntent.getCustomer())) {
throw new PaymentPluginApiException("USER", "Unable to add payment method : setupIntent customerId is " + setupIntent.getCustomer() + " but account already mapped to " + existingCustomerId);
}

// Used below to create the row in the plugin
// TODO This implicitly assumes the payment method type if "payment_method", is this always true?
paymentMethodIdInStripe = paymentIntent.getPaymentMethod();
paymentMethodIdInStripe = setupIntent.getPaymentMethod();
} else {
throw new PaymentPluginApiException("EXTERNAL", "Unable to add payment method: paymentIntent is " + paymentIntent.getStatus());
throw new PaymentPluginApiException("EXTERNAL", "Unable to add payment method: setupIntent status is: " + setupIntent.getStatus());
}
} catch (final SQLException e) {
throw new PaymentPluginApiException("Unable to add payment method", e);
Expand Down Expand Up @@ -425,10 +429,13 @@ public List<PaymentMethodInfoPlugin> getPaymentMethods(final UUID kbAccountId, f
// Start with PaymentMethod...
final Map<String, Object> paymentMethodParams = new HashMap<String, Object>();
paymentMethodParams.put("customer", stripeCustomerId);
// Only supported type by Stripe for now
paymentMethodParams.put("type", "card");
final Iterable<PaymentMethod> stripePaymentMethods = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable();
syncPaymentMethods(kbAccountId, stripePaymentMethods, existingPaymentMethodByStripeId, stripeObjectsTreated, context);
final Iterable<PaymentMethod> stripePaymentMethodsCard = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable();
syncPaymentMethods(kbAccountId, stripePaymentMethodsCard, existingPaymentMethodByStripeId, stripeObjectsTreated, context);

paymentMethodParams.put("type", "sepa_debit");
final Iterable<PaymentMethod> stripePaymentMethodsSepaDebit = PaymentMethod.list(paymentMethodParams, requestOptions).autoPagingIterable();
syncPaymentMethods(kbAccountId, stripePaymentMethodsSepaDebit, existingPaymentMethodByStripeId, stripeObjectsTreated, context);

// Then go through the sources
final PaymentSourceCollection psc = Customer.retrieve(stripeCustomerId, expandSourcesParams, requestOptions).getSources();
Expand Down Expand Up @@ -649,42 +656,56 @@ RequestOptions buildRequestOptions(final TenantContext context) {

@Override
public HostedPaymentPageFormDescriptor buildFormDescriptor(final UUID kbAccountId, final Iterable<PluginProperty> customFields, final Iterable<PluginProperty> properties, final CallContext context) throws PaymentPluginApiException {
final RequestOptions requestOptions = buildRequestOptions(context);

final Account account = getAccount(kbAccountId, context);
final String defaultCurrency = account.getCurrency() != null ? account.getCurrency().name() : "USD";

String stripeCustomerId = getCustomerIdNoException(kbAccountId, context);
if (stripeCustomerId == null) {
// add new customer to stripe account
Map<String, Object> address = new HashMap<>();
address.put("city", account.getCity());
address.put("country", account.getCountry());
address.put("line1", account.getAddress1());
address.put("line2", account.getAddress2());
address.put("postal_code", account.getPostalCode());
address.put("state", account.getStateOrProvince());
Map<String, Object> params = new HashMap<>();
params.put("email", account.getEmail());
params.put("name", account.getName());
params.put("address", address);
params.put("description", "created via KB");
try {
Customer customer = Customer.create(params, requestOptions);
stripeCustomerId = customer.getId();
} catch (StripeException e) {
throw new PaymentPluginApiException("Unable to create Stripe customer", e);
}
}

final Map<String, Object> params = new HashMap<String, Object>();
final Map<String, Object> metadata = new HashMap<String, Object>();
StreamSupport.stream(customFields.spliterator(), false)
.filter(entry -> !metadataFilter.contains(entry.getKey()))
.forEach(p -> metadata.put(p.getKey(), p.getValue()));
params.put("metadata", metadata);

// Stripe doesn't support anything else yet
final ArrayList<String> paymentMethodTypes = new ArrayList<>();
paymentMethodTypes.add("card");
params.put("payment_method_types", paymentMethodTypes);

final ArrayList<HashMap<String, Object>> lineItems = new ArrayList<>();
final HashMap<String, Object> lineItem = new HashMap<String, Object>();
lineItem.put("name", PluginProperties.getValue("line_item_name", "Authorization charge", customFields));
lineItem.put("amount", PluginProperties.getValue("line_item_amount", "100", customFields));
lineItem.put("currency", PluginProperties.getValue("line_item_currency", defaultCurrency, customFields));
lineItem.put("quantity", PluginProperties.getValue("line_item_quantity", "1", customFields));
lineItems.add(lineItem);
params.put("line_items", lineItems);

final HashMap<String, Object> paymentIntentData = new HashMap<String, Object>();
// Auth only
paymentIntentData.put("capture_method", "manual");
params.put("payment_intent_data", paymentIntentData);

params.put("success_url", PluginProperties.getValue("success_url", "https://example.com/success", customFields));
params.put("customer", stripeCustomerId);

final List<String> defaultPaymentMethodTypes = new ArrayList<String>();
defaultPaymentMethodTypes.add("card");
final PluginProperty customPaymentMethods = StreamSupport.stream(customFields.spliterator(), false)
.filter(entry -> "payment_method_types".equals(entry.getKey()))
.findFirst().orElse(null);
params.put("payment_method_types", customPaymentMethods != null && customPaymentMethods.getValue() != null ? customPaymentMethods.getValue() : defaultPaymentMethodTypes);

params.put("mode", "setup");
params.put("success_url", PluginProperties.getValue("success_url", "https://example.com/success?sessionId={CHECKOUT_SESSION_ID}", customFields));
params.put("cancel_url", PluginProperties.getValue("cancel_url", "https://example.com/cancel", customFields));
final StripeConfigProperties stripeConfigProperties = stripeConfigPropertiesConfigurationHandler.getConfigurable(context.getTenantId());

try {
logger.info("Creating Stripe session");
final Session session = Session.create(params, buildRequestOptions(context));
final Session session = Session.create(params, requestOptions);

dao.addHppRequest(kbAccountId,
null,
Expand Down Expand Up @@ -733,10 +754,12 @@ private PaymentTransactionInfoPlugin executeInitialTransaction(final Transaction
public PaymentIntent execute(final Account account, final StripePaymentMethodsRecord paymentMethodsRecord) throws StripeException {
final RequestOptions requestOptions = buildRequestOptions(context);

final CaptureMethod captureMethod = transactionType == TransactionType.AUTHORIZE ? CaptureMethod.MANUAL : CaptureMethod.AUTOMATIC;

final Map<String, Object> paymentIntentParams = new HashMap<>();
paymentIntentParams.put("amount", KillBillMoney.toMinorUnits(currency.toString(), amount));
paymentIntentParams.put("currency", currency.toString());
paymentIntentParams.put("capture_method", transactionType == TransactionType.AUTHORIZE ? "manual" : "automatic");
paymentIntentParams.put("capture_method", captureMethod.value);
// TODO Do we need to switch to manual confirmation to be able to set off_session=recurring?
paymentIntentParams.put("confirm", true);
// See https://stripe.com/docs/api/payment_intents/create#create_payment_intent-return_url
Expand Down Expand Up @@ -770,6 +793,9 @@ public PaymentIntent execute(final Account account, final StripePaymentMethodsRe

final ImmutableList.Builder<String> paymentMethodTypesBuilder = ImmutableList.builder();
paymentMethodTypesBuilder.add("card");
if (captureMethod == CaptureMethod.AUTOMATIC && currency == Currency.EUR) {
paymentMethodTypesBuilder.add("sepa_debit");
}
if (transactionType == TransactionType.PURCHASE && currency == Currency.USD) {
// See https://groups.google.com/forum/?#!msg/killbilling-users/li3RNs-YmIA/oaUrBElMFQAJ
paymentMethodTypesBuilder.add("ach_debit");
Expand Down
Loading