diff --git a/README.md b/README.md index 4f0618c..1129dd1 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -171,3 +176,79 @@ curl -v \ -H "X-Killbill-ApiSecret: lazar" \ 'http://127.0.0.1:8080/1.0/kb/payments/?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/?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": "", + "amount": "25.00", + "currency": "GBP", + "kbPaymentId": "", + "kbTransactionId": "", + "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=' +``` + +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/?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. diff --git a/pom.xml b/pom.xml index 9612a0b..7785034 100644 --- a/pom.xml +++ b/pom.xml @@ -50,7 +50,7 @@ com.gocardless gocardless-pro - 3.10.0 + 7.6.0 com.google.code.findbugs diff --git a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessActivator.java b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessActivator.java index a977830..ea1542c 100644 --- a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessActivator.java +++ b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessActivator.java @@ -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) diff --git a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantBankPay.java b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantBankPay.java new file mode 100644 index 0000000..040fe28 --- /dev/null +++ b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantBankPay.java @@ -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 Taking an Instant Bank Payment + */ +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 toMap() { + Map m = new HashMap<>(); + m.put("formUrl", authorizationUrl); + m.put("billingRequestId", billingRequestId); + return m; + } + } +} diff --git a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCheckoutServlet.java b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCheckoutServlet.java new file mode 100644 index 0000000..64aa252 --- /dev/null +++ b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCheckoutServlet.java @@ -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 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 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 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 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); + } + } +} diff --git a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCompleteServlet.java b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCompleteServlet.java new file mode 100644 index 0000000..496923e --- /dev/null +++ b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessInstantCompleteServlet.java @@ -0,0 +1,101 @@ +/* + * 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 org.jooby.MediaType; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.Status; +import org.jooby.mvc.GET; +import org.jooby.mvc.Local; +import org.jooby.mvc.POST; +import org.jooby.mvc.Path; +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 javax.inject.Named; +import javax.inject.Singleton; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Servlet to complete an Instant Bank Pay flow: fulfil the Billing Request and store the payment ID + * so Kill Bill can resolve it via getPaymentInfo. + * + * GET or POST /plugins/killbill-gocardless/instant/complete?billing_request_id=BRQxxx + * Optional query: kbAccountId (for context). Tenant is taken from Kill Bill context. + */ +@Singleton +@Path("/instant/complete") +public class GoCardlessInstantCompleteServlet { + + private final OSGIKillbillClock clock; + private final GoCardlessPaymentPluginApi goCardlessPaymentPluginApi; + private static final Logger logger = LoggerFactory.getLogger(GoCardlessInstantCompleteServlet.class); + + @javax.inject.Inject + public GoCardlessInstantCompleteServlet(final OSGIKillbillClock clock, + final GoCardlessPaymentPluginApi goCardlessPaymentPluginApi) { + this.clock = clock; + this.goCardlessPaymentPluginApi = goCardlessPaymentPluginApi; + } + + @GET + public Result completeGet(@Named("billing_request_id") final String billingRequestId, + @Named("kbAccountId") final Optional kbAccountId, + @Local @Named("killbill_tenant") final Tenant tenant) throws PaymentPluginApiException { + return doComplete(billingRequestId, kbAccountId.orElse(null), tenant); + } + + @POST + public Result completePost(@Named("billing_request_id") final String billingRequestId, + @Named("kbAccountId") final Optional kbAccountId, + @Local @Named("killbill_tenant") final Tenant tenant) throws PaymentPluginApiException { + return doComplete(billingRequestId, kbAccountId.orElse(null), tenant); + } + + private Result doComplete(final String billingRequestId, final UUID kbAccountId, final Tenant tenant) + throws PaymentPluginApiException { + logger.info("instant/complete: billing_request_id={}", billingRequestId); + + if (billingRequestId == null || billingRequestId.isEmpty()) { +Map errBody = new HashMap<>(); +errBody.put("error", "Missing billing_request_id"); +return Results.with(errBody, Status.BAD_REQUEST).type(MediaType.json); + } + + CallContext context = new PluginCallContext(GoCardlessActivator.PLUGIN_NAME, clock.getClock().getUTCNow(), + kbAccountId != null ? kbAccountId : UUID.randomUUID(), tenant.getId()); + + try { + Map result = goCardlessPaymentPluginApi.fulfilInstantCheckoutAndStore(billingRequestId, context); + return Results.with(result, Status.OK).type(MediaType.json); + } catch (PaymentPluginApiException e) { + logger.warn("Error completing instant checkout", e); + Map err = new HashMap<>(); + err.put("success", false); + err.put("error", e.getMessage()); + return Results.with(err, Status.BAD_REQUEST).type(MediaType.json); + } + } +} diff --git a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessPaymentPluginApi.java index d797fcf..c07b297 100644 --- a/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/gocardless/GoCardlessPaymentPluginApi.java @@ -47,6 +47,7 @@ import com.gocardless.GoCardlessClient; import com.gocardless.errors.GoCardlessApiException; +import com.gocardless.resources.BillingRequest; import com.gocardless.resources.Mandate; import com.gocardless.resources.Payment; import com.gocardless.resources.RedirectFlow; @@ -56,6 +57,9 @@ import org.killbill.billing.plugin.api.core.PluginCustomField; import org.killbill.billing.ObjectType; +import java.util.HashMap; +import java.util.Map; + public class GoCardlessPaymentPluginApi implements PaymentPluginApi { private static final Logger logger = LoggerFactory.getLogger(GoCardlessPaymentPluginApi.class); private final OSGIKillbillAPI killbillAPI; @@ -251,37 +255,153 @@ public List getPaymentInfo(UUID kbAccountId, UUID Iterable properties, TenantContext context) throws PaymentPluginApiException { logger.info("getPaymentInfo, kbAccountId={}", kbAccountId); List paymentTransactionInfoPluginList = new ArrayList<>(); - String mandateId = getMandateId(kbAccountId, context) ; final GoCardlessClient client = buildGoCardlessClient(context); - Mandate mandate = client.mandates().get(mandateId).execute(); //get GoCardless Mandate object - String customerId = mandate.getLinks().getCustomer(); //retrieve customer id from mandate - - Iterable payments = client.payments().all().withCustomer(customerId).execute(); //get all payments related to customer - + if (client == null) { + return paymentTransactionInfoPluginList; + } + + // Instant Bank Pay: check for payment stored via IBP flow (custom field GOCARDLESS_IBP_PAYMENT_) + String ibpPaymentId = getIbpPaymentId(kbAccountId, kbPaymentId, context); + if (ibpPaymentId != null) { + try { + Payment payment = client.payments().get(ibpPaymentId).execute(); + Currency killBillCurrency = convertGoCardlessCurrencyToKillBillCurrency(payment.getCurrency()); + PaymentPluginStatus status = convertGoCardlessToKillBillStatus(payment.getStatus()); + String kbTransactionPaymentIdStr = payment.getMetadata() != null && payment.getMetadata().get("kbTransactionId") != null + ? String.valueOf(payment.getMetadata().get("kbTransactionId")) : null; + UUID kbTransactionPaymentId = kbTransactionPaymentIdStr != null ? UUID.fromString(kbTransactionPaymentIdStr) : null; + List outputProperties = new ArrayList<>(); + outputProperties.add(new PluginProperty("gocardlessPaymentId", payment.getId(), false)); + outputProperties.add(new PluginProperty("gocardlessstatus", payment.getStatus(), false)); + outputProperties.add(new PluginProperty("paymentType", "INSTANT_BANK_PAY", false)); + GoCardlessPaymentTransactionInfoPlugin plugin = new GoCardlessPaymentTransactionInfoPlugin( + kbPaymentId, kbTransactionPaymentId, TransactionType.PURCHASE, + KillBillMoney.fromMinorUnits(String.valueOf(killBillCurrency), Long.valueOf(payment.getAmount())), killBillCurrency, + status, null, null, String.valueOf(payment.getId()), null, new DateTime(), + new DateTime(payment.getCreatedAt()), outputProperties); + paymentTransactionInfoPluginList.add(plugin); + return paymentTransactionInfoPluginList; + } catch (GoCardlessApiException e) { + logger.warn("Failed to get IBP payment {} from GoCardless", ibpPaymentId, e); + } + } + + // Mandate-based (Direct Debit) payments + String mandateId = getMandateId(kbAccountId, context); + if (mandateId == null) { + return paymentTransactionInfoPluginList; + } + Mandate mandate = client.mandates().get(mandateId).execute(); + String customerId = mandate.getLinks().getCustomer(); + + Iterable payments = client.payments().all().withCustomer(customerId).execute(); for (Payment payment : payments) { - String kbPaymentIdFromPayment = payment.getMetadata().get("kbPaymentId"); //get kbPaymentId from metadata in payment - if(kbPaymentIdFromPayment != null && kbPaymentId.toString().equals(kbPaymentIdFromPayment)) { + String kbPaymentIdFromPayment = payment.getMetadata() != null ? payment.getMetadata().get("kbPaymentId") : null; + if (kbPaymentIdFromPayment != null && kbPaymentId.toString().equals(kbPaymentIdFromPayment)) { Currency killBillCurrency = convertGoCardlessCurrencyToKillBillCurrency(payment.getCurrency()); PaymentPluginStatus status = convertGoCardlessToKillBillStatus(payment.getStatus()); - String kbTransactionPaymentIdStr = payment.getMetadata().get("kbTransactionId"); - UUID kbTransactionPaymentId = kbTransactionPaymentIdStr !=null?UUID.fromString(kbTransactionPaymentIdStr):null; - List outputProperties = new ArrayList(); - outputProperties.add(new PluginProperty("mandateId",mandateId,false)); //arbitrary data to be returned to the caller - outputProperties.add(new PluginProperty("customerId",customerId,false)); //arbitrary data to be returned to the caller - outputProperties.add(new PluginProperty("gocardlessstatus",payment.getStatus(),false)); //arbitrary data to be returned to the caller + String kbTransactionPaymentIdStr = payment.getMetadata() != null ? payment.getMetadata().get("kbTransactionId") : null; + UUID kbTransactionPaymentId = kbTransactionPaymentIdStr != null ? UUID.fromString(kbTransactionPaymentIdStr) : null; + List outputProperties = new ArrayList<>(); + outputProperties.add(new PluginProperty("mandateId", mandateId, false)); + outputProperties.add(new PluginProperty("customerId", customerId, false)); + outputProperties.add(new PluginProperty("gocardlessstatus", payment.getStatus(), false)); GoCardlessPaymentTransactionInfoPlugin paymentTransactionInfoPlugin = new GoCardlessPaymentTransactionInfoPlugin( - kbPaymentId, kbTransactionPaymentId, TransactionType.PURCHASE, // In a real-world plugin, set to the appropriate TransactionType + kbPaymentId, kbTransactionPaymentId, TransactionType.PURCHASE, KillBillMoney.fromMinorUnits(String.valueOf(killBillCurrency), Long.valueOf(payment.getAmount())), killBillCurrency, status, null, null, String.valueOf(payment.getId()), null, new DateTime(), - new DateTime(payment.getCreatedAt()), outputProperties); - logger.info("Created paymentTransactionInfoPlugin {}",paymentTransactionInfoPlugin); + new DateTime(payment.getCreatedAt()), outputProperties); paymentTransactionInfoPluginList.add(paymentTransactionInfoPlugin); } } - return paymentTransactionInfoPluginList; } + /** Custom field name prefix for IBP payment ID stored per Kill Bill payment. */ + public static final String CUSTOM_FIELD_IBP_PAYMENT_PREFIX = "GOCARDLESS_IBP_PAYMENT_"; + + private String getIbpPaymentId(UUID kbAccountId, UUID kbPaymentId, TenantContext context) { + final List customFields = killbillAPI.getCustomFieldUserApi() + .getCustomFieldsForAccountType(kbAccountId, ObjectType.ACCOUNT, context); + String fieldName = CUSTOM_FIELD_IBP_PAYMENT_PREFIX + kbPaymentId.toString(); + for (CustomField cf : customFields) { + if (fieldName.equals(cf.getFieldName())) { + return cf.getFieldValue(); + } + } + return null; + } + + /** + * Creates an Instant Bank Pay checkout session (Billing Request + Flow). Returns the URL to redirect the customer to. + */ + public GoCardlessInstantBankPay.InstantCheckoutResult createInstantCheckoutSession(UUID kbAccountId, BigDecimal amount, + Currency currency, UUID kbPaymentId, UUID kbTransactionId, String successRedirectUrl, String description, + CallContext context) throws PaymentPluginApiException { + GoCardlessClient client = buildGoCardlessClient(context); + if (client == null) { + throw new PaymentPluginApiException("GoCardless client not configured for tenant"); + } + try { + return GoCardlessInstantBankPay.createInstantCheckout(client, amount, currency, kbAccountId, kbPaymentId, + kbTransactionId, description, successRedirectUrl); + } catch (GoCardlessApiException e) { + logger.warn("GoCardless API error creating instant checkout", e); + throw new PaymentPluginApiException("GoCardless error: " + e.getMessage(), e); + } + } + + /** + * Fulfils an Instant Bank Pay Billing Request and stores the resulting payment ID in Kill Bill custom fields + * so getPaymentInfo can resolve it. + */ + public Map fulfilInstantCheckoutAndStore(String billingRequestId, CallContext context) throws PaymentPluginApiException { + GoCardlessClient client = buildGoCardlessClient(context); + if (client == null) { + throw new PaymentPluginApiException("GoCardless client not configured for tenant"); + } + try { + BillingRequest br = client.billingRequests().get(billingRequestId).execute(); + @SuppressWarnings("unchecked") + Map meta = br.getMetadata() != null ? (Map) br.getMetadata() : null; + if (meta == null) { + throw new PaymentPluginApiException("Billing Request has no metadata (kbAccountId, kbPaymentId)"); + } + String kbAccountIdStr = meta.get("kbAccountId"); + String kbPaymentIdStr = meta.get("kbPaymentId"); + if (kbAccountIdStr == null || kbPaymentIdStr == null) { + throw new PaymentPluginApiException("Billing Request metadata missing kbAccountId or kbPaymentId"); + } + } + UUID kbAccountId = UUID.fromString(kbAccountIdStr); + UUID kbPaymentId = UUID.fromString(kbPaymentIdStr); + + String paymentId = GoCardlessInstantBankPay.fulfilBillingRequest(client, billingRequestId); + if (paymentId == null) { + throw new PaymentPluginApiException("Fulfil did not return a payment ID"); + } + + String fieldName = CUSTOM_FIELD_IBP_PAYMENT_PREFIX + kbPaymentId.toString(); + killbillAPI.getCustomFieldUserApi().addCustomFields( + ImmutableList.of(new PluginCustomField(kbAccountId, ObjectType.ACCOUNT, fieldName, paymentId, clock.getUTCNow())), + context); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("billingRequestId", billingRequestId); + result.put("paymentId", paymentId); + result.put("kbAccountId", kbAccountId.toString()); + result.put("kbPaymentId", kbPaymentId.toString()); + return result; + } catch (GoCardlessApiException e) { + logger.warn("GoCardless error fulfilling Billing Request", e); + throw new PaymentPluginApiException("GoCardless error: " + e.getMessage(), e); + } catch (CustomFieldApiException e) { + logger.warn("Failed to store IBP payment custom field", e); + throw new PaymentPluginApiException("Failed to store payment reference", e); + } + } + /** * Converts GoCardless status to Kill Bill status