Skip to content
Open
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
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

Kill Bill payment plugin that uses [Gocardless](https://gocardless.com/) as the payment gateway.

The plugin supports two payment flows:

- **Direct Debit (mandate)** – Customer sets up a mandate via a redirect flow; payments are then taken on that mandate (typically 3–5 working days to settle).
- **Instant Bank Pay (IBP)** – One-off instant payments via [GoCardless Billing Requests](https://developer.gocardless.com/billing-requests/taking-an-instant-bank-payment). Funds are confirmed within minutes (UK Faster Payments, SEPA Instant, etc.).

## Kill Bill compatibility

| Plugin version | Kill Bill version |
Expand Down Expand Up @@ -171,3 +176,79 @@ curl -v \
-H "X-Killbill-ApiSecret: lazar" \
'http://127.0.0.1:8080/1.0/kb/payments/<PAYMENT_ID>?withPluginInfo=false'
```

## Instant Bank Pay (IBP)

Instant Bank Pay uses GoCardless Billing Requests for one-off instant payments (no mandate). The customer is redirected to authorise the payment with their bank; funds are confirmed within minutes.

**Flow:**

1. **Create a payment in Kill Bill** (e.g. for an invoice) so you have `kbPaymentId` and `kbTransactionId`.
2. **Start an instant checkout** – POST to the plugin with account, amount, currency, payment IDs, and a success redirect URL. The plugin creates a Billing Request and Billing Request Flow and returns a URL.
3. **Redirect the customer** to that URL. They complete the flow (select bank, authorise).
4. **On success**, GoCardless redirects to your `success_redirect_url`. Your page should call the plugin **complete** endpoint with the `billing_request_id` (from the redirect query string). The plugin fulfils the Billing Request and stores the GoCardless payment ID so Kill Bill can resolve it via `getPaymentInfo`.
5. **Kill Bill** can then fetch payment status with `GET /1.0/kb/payments/<PAYMENT_ID>?withPluginInfo=true` as usual.

### Create instant checkout (step 2)

```
curl -v -X POST \
-u admin:password \
-H "X-Killbill-ApiKey: bob" \
-H "X-Killbill-ApiSecret: lazar" \
-H "X-Killbill-CreatedBy: tutorial" \
-H "Content-Type: application/json" \
-d '{
"kbAccountId": "<ACCOUNT_ID>",
"amount": "25.00",
"currency": "GBP",
"kbPaymentId": "<KB_PAYMENT_ID>",
"kbTransactionId": "<KB_TRANSACTION_ID>",
"success_redirect_url": "https://your-app.com/payment/success",
"description": "Order #12345"
}' \
'http://127.0.0.1:8080/plugins/killbill-gocardless/checkout/instant'
```

Response (201):

```json
{
"formUrl": "https://pay-sandbox.gocardless.com/...",
"formMethod": "GET",
"billingRequestId": "BRQ...",
"kbAccountId": "...",
"kbPaymentId": "...",
"kbTransactionId": "..."
}
```

Redirect the customer to `formUrl`.

### Complete instant payment (step 4)

When the customer returns to your `success_redirect_url`, GoCardless appends a query parameter such as `billing_request_id=BRQ...`. Your server or front end should call:

```
curl -v -X GET \
-u admin:password \
-H "X-Killbill-ApiKey: bob" \
-H "X-Killbill-ApiSecret: lazar" \
'http://127.0.0.1:8080/plugins/killbill-gocardless/instant/complete?billing_request_id=<BILLING_REQUEST_ID>'
```

Or POST with the same query parameter. Response (200):

```json
{
"success": true,
"billingRequestId": "BRQ...",
"paymentId": "PM...",
"kbAccountId": "...",
"kbPaymentId": "..."
}
```

After this, Kill Bill’s `getPaymentInfo` (and thus `GET /1.0/kb/payments/<PAYMENT_ID>?withPluginInfo=true`) will return the instant payment status.

**Note:** IBP is supported for one-off payments in regions/schemes supported by GoCardless (e.g. UK Faster Payments, SEPA Instant). See [GoCardless Instant Bank Pay](https://developer.gocardless.com/billing-requests/taking-an-instant-bank-payment) for details.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<dependency>
<groupId>com.gocardless</groupId>
<artifactId>gocardless-pro</artifactId>
<version>3.10.0</version>
<version>7.6.0</version>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public void start(final BundleContext context) throws Exception {
// Register the servlet, which is used as the entry point to generate the Hosted Payment Pages redirect url
final PluginApp pluginApp = new PluginAppBuilder(PLUGIN_NAME, killbillAPI, dataSource, super.clock, configProperties)
.withRouteClass(GoCardlessCheckoutServlet.class)
.withRouteClass(GoCardlessInstantCheckoutServlet.class)
.withRouteClass(GoCardlessInstantCompleteServlet.class)
.withRouteClass(GoCardlessHealthCheckServlet.class).withService(healthcheck)
.withService(pluginApi)
.withService(clock)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright 2021 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.gocardless;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.plugin.util.KillBillMoney;
import org.killbill.billing.util.callcontext.TenantContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.gocardless.GoCardlessClient;
import com.gocardless.errors.GoCardlessApiException;
import com.gocardless.resources.BillingRequest;
import com.gocardless.resources.BillingRequestFlow;

/**
* Helper for GoCardless Instant Bank Pay (IBP) using Billing Requests.
* IBP allows one-off instant payments without a mandate; funds are confirmed within minutes.
*
* @see <a href="https://developer.gocardless.com/billing-requests/taking-an-instant-bank-payment">Taking an Instant Bank Payment</a>
*/
public final class GoCardlessInstantBankPay {

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

private GoCardlessInstantBankPay() {}

/**
* Creates a Billing Request and Billing Request Flow for an instant bank payment.
* The customer should be redirected to the returned URL to authorise the payment.
*
* @param client GoCardless client
* @param amount amount in the currency's major unit (e.g. 10.00 GBP)
* @param currency currency code (e.g. GBP, EUR)
* @param kbAccountId Kill Bill account ID (stored in metadata for fulfil step)
* @param kbPaymentId Kill Bill payment ID (stored in metadata)
* @param kbTransactionId Kill Bill transaction ID (stored in metadata)
* @param description human-readable description shown to the payer
* @param redirectUri URL to redirect to after the customer completes the flow (success or exit)
* @return result with authorization URL and billing request ID
*/
public static InstantCheckoutResult createInstantCheckout(
final GoCardlessClient client,
final BigDecimal amount,
final Currency currency,
final UUID kbAccountId,
final UUID kbPaymentId,
final UUID kbTransactionId,
final String description,
final String redirectUri) throws GoCardlessApiException {

int amountMinor = Math.toIntExact(KillBillMoney.toMinorUnits(currency.toString(), amount));
String currencyCode = currency.toString();

BillingRequest billingRequest = client.billingRequests().create()
.withPaymentRequestAmount(amountMinor)
.withPaymentRequestCurrency(currencyCode)
.withPaymentRequestDescription(description != null ? description : "Instant payment")
.withMetadata("kbAccountId", kbAccountId.toString())
.withMetadata("kbPaymentId", kbPaymentId.toString())
.withMetadata("kbTransactionId", kbTransactionId.toString())
.execute();

logger.info("Created Billing Request for IBP: id={}", billingRequest.getId());

BillingRequestFlow flow = client.billingRequestFlows().create()
.withLinksBillingRequest(billingRequest.getId())
.withRedirectUri(redirectUri)
.execute();

// SDK uses British spelling: getAuthorisationUrl()
String authorizationUrl = flow.getAuthorisationUrl();
if (authorizationUrl == null || authorizationUrl.isEmpty()) {
authorizationUrl = flow.getRedirectUri();
}
if (authorizationUrl == null || authorizationUrl.isEmpty()) {
throw new IllegalStateException("Billing Request Flow did not return an authorization/redirect URL");
}

return new InstantCheckoutResult(authorizationUrl, billingRequest.getId());
}

/**
* Fulfils a Billing Request that is ready_to_fulfil (after the customer has completed the flow).
* This creates the payment and returns the GoCardless payment ID.
*
* @param client GoCardless client
* @param billingRequestId the Billing Request ID (e.g. from redirect query param)
* @return the created payment ID, or null if the billing request has no payment link
*/
public static String fulfilBillingRequest(final GoCardlessClient client, final String billingRequestId)
throws GoCardlessApiException {
BillingRequest billingRequest = client.billingRequests().fulfil(billingRequestId).execute();
logger.info("Fulfilled Billing Request: id={}", billingRequestId);

// After fulfil, the payment is linked via payment_request_payment
if (billingRequest.getLinks() == null) {
return null;
}
return billingRequest.getLinks().getPaymentRequestPayment();
}

/**
* Result of creating an instant checkout session.
*/
public static final class InstantCheckoutResult {
private final String authorizationUrl;
private final String billingRequestId;

public InstantCheckoutResult(final String authorizationUrl, final String billingRequestId) {
this.authorizationUrl = authorizationUrl;
this.billingRequestId = billingRequestId;
}

public String getAuthorizationUrl() {
return authorizationUrl;
}

public String getBillingRequestId() {
return billingRequestId;
}

public Map<String, String> toMap() {
Map<String, String> m = new HashMap<>();
m.put("formUrl", authorizationUrl);
m.put("billingRequestId", billingRequestId);
return m;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2021 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.gocardless;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import org.jooby.MediaType;
import org.jooby.Result;
import org.jooby.Results;
import org.jooby.Status;
import org.jooby.mvc.Local;
import org.jooby.mvc.POST;
import org.jooby.mvc.Path;
import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.osgi.libs.killbill.OSGIKillbillClock;
import org.killbill.billing.payment.plugin.api.PaymentPluginApiException;
import org.killbill.billing.plugin.api.PluginCallContext;
import org.killbill.billing.tenant.api.Tenant;
import org.killbill.billing.util.callcontext.CallContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.gocardless.errors.GoCardlessApiException;

import javax.inject.Named;
import javax.inject.Singleton;

/**
* Servlet for Instant Bank Pay (IBP) checkout.
* Creates a Billing Request and Billing Request Flow; the client redirects the customer to the returned URL.
*
* POST /plugins/killbill-gocardless/checkout/instant
* Query/body: kbAccountId, amount, currency, kbPaymentId, kbTransactionId, success_redirect_url, description (optional)
*/
@Singleton
@Path("/checkout/instant")
public class GoCardlessInstantCheckoutServlet {

private final OSGIKillbillClock clock;
private final GoCardlessPaymentPluginApi goCardlessPaymentPluginApi;
private static final Logger logger = LoggerFactory.getLogger(GoCardlessInstantCheckoutServlet.class);

@javax.inject.Inject
public GoCardlessInstantCheckoutServlet(final OSGIKillbillClock clock,
final GoCardlessPaymentPluginApi goCardlessPaymentPluginApi) {
this.clock = clock;
this.goCardlessPaymentPluginApi = goCardlessPaymentPluginApi;
}

@POST
public Result createInstantCheckout(
@Named("kbAccountId") final UUID kbAccountId,
@Named("amount") final BigDecimal amount,
@Named("currency") final String currencyCode,
@Named("kbPaymentId") final UUID kbPaymentId,
@Named("kbTransactionId") final UUID kbTransactionId,
@Named("success_redirect_url") final String successRedirectUrl,
@Named("description") final java.util.Optional<String> description,
@Local @Named("killbill_tenant") final Tenant tenant) throws PaymentPluginApiException {
logger.info("createInstantCheckout: kbAccountId={}, amount={}, currency={}", kbAccountId, amount, currencyCode);

if (kbAccountId == null || amount == null || currencyCode == null || kbPaymentId == null
|| kbTransactionId == null || successRedirectUrl == null || successRedirectUrl.isEmpty()) {
Map<String, Object> errBody = new HashMap<>();
errBody.put("error", "Missing required parameters: kbAccountId, amount, currency, kbPaymentId, kbTransactionId, success_redirect_url");
return Results.with(errBody, Status.BAD_REQUEST).type(MediaType.json);

Currency currency;
try {
currency = Currency.valueOf(currencyCode);
} catch (IllegalArgumentException e) {
Map<String, Object> errBody = new HashMap<>();
errBody.put("error", "Invalid currency: " + currencyCode);
return Results.with(errBody, Status.BAD_REQUEST).type(MediaType.json);
}

CallContext context = new PluginCallContext(GoCardlessActivator.PLUGIN_NAME, clock.getClock().getUTCNow(), kbAccountId, tenant.getId());

try {
GoCardlessInstantBankPay.InstantCheckoutResult result = goCardlessPaymentPluginApi.createInstantCheckoutSession(
kbAccountId, amount, currency, kbPaymentId, kbTransactionId,
successRedirectUrl, description.orElse("Instant payment"), context);

Map<String, Object> body = new HashMap<>();
body.put("formUrl", result.getAuthorizationUrl());
body.put("formMethod", "GET");
body.put("billingRequestId", result.getBillingRequestId());
body.put("kbAccountId", kbAccountId.toString());
body.put("kbPaymentId", kbPaymentId.toString());
body.put("kbTransactionId", kbTransactionId.toString());

return Results.with(body, Status.CREATED).type(MediaType.json);
} catch (GoCardlessApiException e) {
logger.warn("GoCardless API error creating instant checkout", e);
throw new PaymentPluginApiException("GoCardless error: " + e.getMessage(), e);
}
}
}
Loading
Loading