From 68a3e71c95922482d59b684407b834f2b7e09cd5 Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:26:21 +0530 Subject: [PATCH 1/7] Added fix to update transaction status 1) Added fix to update transaction status 2) Updated readme 3) Updated lombok version --- README.md | 47 +++++++++++++------ pom.xml | 4 +- .../adyen/api/AdyenPaymentPluginApi.java | 31 +++++++++++- .../billing/plugin/adyen/dao/AdyenDao.java | 7 ++- 4 files changed, 70 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c82dc0e3..2da8752e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,10 @@ Version `0.8.0` of the plugin uses Adyen classic integration while version `0.9. ## Requirements -The plugin needs a database. The latest version of the schema can be found [here](https://github.com/killbill/killbill-adyen-plugin/tree/master/src/main/resources). +The plugin needs a database and does not create its tables automatically. Before starting the plugin for the first time, run the respective DDL on the Kill Bill database: + +* MySQL: [ddl.sql](https://github.com/killbill/killbill-adyen-plugin/blob/master/src/main/resources/ddl.sql) +* PostgreSQL: [ddl-postgresql.sql](https://github.com/killbill/killbill-adyen-plugin/blob/master/src/main/resources/ddl-postgresql.sql) ## Build @@ -80,13 +83,28 @@ org.killbill.billing.plugin.adyen.username=xxx ' \ Where: * apiKey: API Key generated at step 3 -* returnUrl: URL set in Step 4c above. Typically `http:///plugins/adyen-plugin/notification` +* returnUrl: URL the shopper is sent back to after the Drop-in payment (passed to Adyen's `/sessions` as `returnUrl`). This is your application's URL, not the notification URL * merchantAccount: Merchant account created at step 2 * hcmaKey: HMAC Key generated at step 4a * captureDelayHours: Desire capture delay in hours after Authorize , number must be between 0 - 168 hr * environment: Environment to use. Possible values are `TEST`/`LIVE`. default value is `TEST` -* password: Password set at step 4b -* username: Username set at step 4b +* username / password: a Kill Bill user login (for example `admin` / `password`). The `/checkout` servlet uses these to authenticate against Kill Bill when it creates the first payment. They are not the webhook Basic Authentication credentials from step 4b; the plugin does not check those + +## Payment status after an Adyen notification + +Payments and refunds are recorded as `PENDING` when they are sent to Adyen. When Adyen's notification arrives (for example `AUTHORISATION` or `REFUND`), the plugin: + +1. Validates the HMAC signature and updates the matching transaction in its own tables. +2. Stores the recurring token on the payment method, if Adyen sent one. +3. For a successful notification, asks Kill Bill core to reconcile the payment right away, so the transaction moves from `PENDING` to `SUCCESS` without waiting for the Janitor. + +Failed notifications, and successful ones whose immediate reconciliation fails (the error is logged), are handled by the Kill Bill Janitor. Leaving failures to the Janitor keeps the payment retry schedule of invoice payments. By default the Janitor checks `PENDING` transactions 1 hour after the payment and again 1 day after that. The delays can be shortened with the `org.killbill.payment.janitor.pending.retries` property, for example: + +``` +org.killbill.payment.janitor.pending.retries=5m,15m,1h,1d +``` + +See the [payment user guide](https://docs.killbill.io/latest/userguide_payment.html) for details. ## Testing @@ -95,19 +113,19 @@ Where: Since Adyen uses webhooks, the `http:///plugins/adyen-plugin/notification` URL needs to be publicly accessible for Adyen to send notifications to it. Thus, testing can be done in one of the following ways: -* **Using AWS**: Test on Kill Bill running on an AWS instance. In this case, you would need to specify the AWS URL (`http://:8080/plugins/adyen-plugin/notification`) as the webhook server URL in Adyen and configure it as the `returnURL` in the Adyen plugin as explained above. +* **Using AWS**: Test on Kill Bill running on an AWS instance. In this case, you would need to specify the AWS URL (`http://:8080/plugins/adyen-plugin/notification`) as the webhook server URL in Adyen. * **Using ngrok**: If you would like to test on a local installation instead of AWS, you can use a tool like [ngrok](https://ngrok.com/) to create a temporary public DNS and redirect traffic from that DNS to the local Kill Bill server. Ngrok can be set up as follows: * Install ngrok from [here](https://ngrok.com/download). * Open a terminal window and type `ngrok http 8080` (Since Kill Bill listens on port `8080`) * This will display a message like `Forwarding https://39dc-117-195-30-48.in.ngrok.io -> http://localhost:8080` - * Copy the URL displayed above (`https://39dc-117-195-30-48.in.ngrok.io`). In this case, you would need to specify the ngrok URL (`/plugins/adyen-plugin/notification`) as the webhook server URL in Adyen and configure it as the `returnURL` in the Adyen plugin as explained above. + * Copy the URL displayed above (`https://39dc-117-195-30-48.in.ngrok.io`). In this case, you would need to specify the ngrok URL (`/plugins/adyen-plugin/notification`) as the webhook server URL in Adyen. ### Testing -1. Build, install and configure the plugin as explained above. Note that either your AWS URL or ngrok URL needs to be specified as the webhook server URL in Adyen and configured as the plugin `returnUrl`. +1. Build, install and configure the plugin as explained above. Note that either your AWS URL or ngrok URL needs to be specified as the webhook server URL in Adyen. 2. Test the application using the [Kill Bill Adyen Demo](https://github.com/killbill/killbill-adyen-demo) as explained [here](https://github.com/killbill/killbill-adyen-demo/#test). 3. Verify that a new account is created in Kill Bill with a payment in `PENDING` status. -4. If all goes well, Adyen would then send a notification to convert the `PENDING` payment status to `SUCCESS`. -5. Retrieve the payment using the `withPluginInfo` parameter to trigger the [Janitor](https://docs.killbill.io/latest/userguide_payment.html#_on_the_fly_janitor) as follows: +4. If all goes well, Adyen would then send a notification and the plugin converts the `PENDING` payment status to `SUCCESS` in Kill Bill. +5. Retrieve the payment as follows (`withPluginInfo=true` also triggers the [on-the-fly Janitor](https://docs.killbill.io/latest/userguide_payment.html#_on_the_fly_janitor), which is useful if the notification was not received): ``` curl \ @@ -117,7 +135,7 @@ curl \ -H 'Accept: application/json' \ 'http://127.0.0.1:8080/1.0/kb/payments/?withPluginInfo=true' ``` -5. Verify that the payment status is converted to `SUCCESS`. +6. Verify that the payment status is `SUCCESS`. ## Plugin Internals @@ -131,7 +149,7 @@ The following steps need to be followed in order to use the Adyen plugin: 2. [Create](https://killbill.github.io/slate/?shell#account-create-an-account) a Kill Bill account and save the `accountId` for further use. -3. Create a Kill Bill Payment Method (Specify a `PluginProperty` corresponding to `enableRecurring` if this is going to be a recurring payment. The default value of this property is `false`): +3. Create a Kill Bill Payment Method. Specify a `PluginProperty` corresponding to `enableRecurring` if this is going to be a recurring payment (the default value of this property is `false`). Set `isDefault` to `true` if Kill Bill should use this payment method to pay invoices automatically: ```bash curl -v \ @@ -179,11 +197,12 @@ curl -v \ Note that this creates a payment in Kill Bill for the specified amount in `PENDING` status. -4. Set up a drop-in with the `sessionId` and `sessionData` obtained above as explained [here](https://docs.adyen.com/online-payments/web-drop-in#set-up). +5. Set up a drop-in with the `sessionId` and `sessionData` obtained above as explained [here](https://docs.adyen.com/online-payments/web-drop-in#set-up). + +6. Collect customer's payment details via the drop-in. -5. Collect customer's payment details via the drop-in. +7. If the payment is successful, Adyen sends a notification to Kill Bill; the plugin stores the recurring token (if `enableRecurring` was set) and moves the payment from `PENDING` to `SUCCESS`. Payments initiated by Kill Bill afterwards (for example for subscription invoices) use the stored token automatically. -6. If the payment is successful, Adyen sends a notification to Kill Bill to convert the `PENDING` status to `SUCCESS`. diff --git a/pom.xml b/pom.xml index 5d961f8f..4b510869 100644 --- a/pom.xml +++ b/pom.xml @@ -321,7 +321,7 @@ org.projectlombok lombok - 1.18.22 + 1.18.34 provided @@ -361,7 +361,7 @@ org.projectlombok lombok - 1.18.22 + 1.18.34 diff --git a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java index 6ecf6190..33507023 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java @@ -590,7 +590,7 @@ public GatewayNotification processNotification( outputDTO.setStatus(PaymentPluginStatus.ERROR); } this.adyenDao.updateResponse( - UUID.fromString(record.getKbPaymentId()), + UUID.fromString(record.getKbPaymentTransactionId()), outputDTO, UUID.fromString(record.getKbTenantId())); this.adyenDao.addNotification( @@ -608,6 +608,14 @@ public GatewayNotification processNotification( notificationItem.getAdditionalData().get(RECURRING_DATA)); } + // The plugin tables are now up to date. On success, let Kill Bill core know right away + // instead of waiting for the Janitor (by default 1 hour later). See #138. + // Failures are left to the scheduled Janitor: it keeps the payment retry schedule of + // invoice payments, which an API-initiated refresh would skip. + if (notificationItem.isSuccess()) { + refreshPaymentInKillBill(UUID.fromString(record.getKbPaymentId()), tempContext); + } + } else { logger.error("HMAC Key is not valid"); } @@ -617,6 +625,27 @@ public GatewayNotification processNotification( return new PluginGatewayNotification("[accepted]"); } + /** + * Fetches the payment with plugin info, which runs the on-the-fly Janitor: Kill Bill core calls + * {@link #getPaymentInfo} and moves the PENDING transaction to SUCCESS based on the status the + * plugin has just recorded, then completes the invoice payment. A failure here is logged and not + * rethrown: the webhook must still be acknowledged, and the scheduled Janitor remains the + * fallback. + */ + private void refreshPaymentInKillBill(final UUID kbPaymentId, final CallContext context) { + try { + this.killbillAPI + .getPaymentApi() + .getPayment(kbPaymentId, true, false, ImmutableList.of(), context); + } catch (final Exception e) { + logger.warn( + "Unable to refresh paymentId='{}' in Kill Bill after the Adyen notification," + + " the Janitor will reconcile it later", + kbPaymentId, + e); + } + } + public Map getAdditionalDataMap(String additionalData) { if (additionalData == null) { return Collections.emptyMap(); diff --git a/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java b/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java index c4edc739..c693f96b 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java @@ -149,7 +149,8 @@ public AdyenPaymentMethodsRecord withConnection(final Connection conn) }); } - public void updateResponse(UUID kbPaymentId, ProcessorOutputDTO outputDTO, UUID tenantId) + public void updateResponse( + UUID kbPaymentTransactionId, ProcessorOutputDTO outputDTO, UUID tenantId) throws SQLException { execute( dataSource.getConnection(), @@ -161,7 +162,9 @@ public AdyenResponsesRecord withConnection(final Connection conn) throws SQLExce .update(ADYEN_RESPONSES) .set(ADYEN_RESPONSES.PSP_REFERENCE, outputDTO.getPspReferenceCode()) .set(ADYEN_RESPONSES.TRANSACTION_STATUS, outputDTO.getStatus().name()) - .where(ADYEN_RESPONSES.KB_PAYMENT_ID.equal(kbPaymentId.toString())) + .where( + ADYEN_RESPONSES.KB_PAYMENT_TRANSACTION_ID.equal( + kbPaymentTransactionId.toString())) .and(ADYEN_RESPONSES.KB_TENANT_ID.equal(tenantId.toString())) .execute(); return null; From d7aae8a47836255e02462148fcca9264e4a3933b Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:10:15 +0530 Subject: [PATCH 2/7] Accept recurringDetailReference and shopperReference on addPaymentMethod --- .../adyen/api/AdyenPaymentPluginApi.java | 42 ++++++++++++- .../adyen/TestAdyenPaymentPluginApi.java | 59 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java index 33507023..ca26bb48 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java @@ -64,6 +64,8 @@ import org.killbill.clock.Clock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.annotations.VisibleForTesting; + public class AdyenPaymentPluginApi extends PluginPaymentPluginApi< @@ -74,6 +76,10 @@ public class AdyenPaymentPluginApi public static final String SESSION_DATA = "sessionData"; public static final String RECURRING_DATA = "recurring.recurringDetailReference"; public static final String ENABLE_RECURRING = "enableRecurring"; + // Token and shopper reference of a payment method tokenised outside the plugin (for example in + // the merchant's own Adyen /sessions call), passed as plugin properties on addPaymentMethod. + public static final String RECURRING_DETAIL_REFERENCE = "recurringDetailReference"; + public static final String SHOPPER_REFERENCE = "shopperReference"; protected static final ObjectMapper objectMapper = new ObjectMapper(); private final AdyenConfigurationHandler adyenConfigurationHandler; private final AdyenDao adyenDao; @@ -209,8 +215,12 @@ public void addPaymentMethod( throw new PaymentMethodException( "Missing one or more configuration properties (HMAC KEY/ Api Key / Merchant Account / Return URL) "); } - if (mergedProperties.get(ENABLE_RECURRING) != null - && mergedProperties.get(ENABLE_RECURRING).equals("true")) { + final String recurringDetailReference = mergedProperties.get(RECURRING_DETAIL_REFERENCE); + final boolean hasToken = + recurringDetailReference != null && !recurringDetailReference.trim().isEmpty(); + if (hasToken + || (mergedProperties.get(ENABLE_RECURRING) != null + && mergedProperties.get(ENABLE_RECURRING).equals("true"))) { recurring = true; } else { recurring = false; @@ -223,6 +233,12 @@ public void addPaymentMethod( recurring, context.getTenantId(), setDefault); + if (hasToken) { + // The payment method was tokenised outside the plugin: store the token now instead of + // waiting for an AUTHORISATION notification of a plugin-initiated payment. + this.adyenDao.updateRecurringDetailsPaymentMethod( + kbPaymentMethodId, context.getTenantId(), recurringDetailReference.trim()); + } } catch (SQLException e) { throw new PaymentMethodException("[addPaymentMethod] Error inserting payment method", e); @@ -335,6 +351,11 @@ public PaymentTransactionInfoPlugin purchasePayment( new PluginProperty(SESSION_DATA, outputDTO.getAdditionalData().get(SESSION_DATA), false)); } else { input.setRecurringData(paymentMethodRecord.getRecurringDetailReference()); + // Adyen only accepts a stored token with the shopperReference it was stored under. For + // payment methods tokenised outside the plugin, that reference was given at creation; + // otherwise the plugin stored the token under the Kill Bill account id. + // Note: the processor sends input.getKbAccountId() to Adyen as the shopperReference. + input.setKbAccountId(getShopperReference(paymentMethodRecord, kbAccountId)); outputDTO = gatewayProcessor.processOneTimePayment(input); } @@ -646,6 +667,23 @@ private void refreshPaymentInKillBill(final UUID kbPaymentId, final CallContext } } + @VisibleForTesting + public String getShopperReference( + final AdyenPaymentMethodsRecord paymentMethodRecord, final UUID kbAccountId) { + final Map data = getAdditionalDataMap(paymentMethodRecord.getAdditionalData()); + final Object shopperReference = data.get(SHOPPER_REFERENCE); + final Object tokenAtCreation = data.get(RECURRING_DETAIL_REFERENCE); + // Use the given reference only for the token it was given with; a token stored later by a + // plugin-initiated payment is always stored under the Kill Bill account id. + if (shopperReference != null + && !shopperReference.toString().trim().isEmpty() + && tokenAtCreation != null + && tokenAtCreation.toString().trim().equals(paymentMethodRecord.getRecurringDetailReference())) { + return shopperReference.toString().trim(); + } + return kbAccountId.toString(); + } + public Map getAdditionalDataMap(String additionalData) { if (additionalData == null) { return Collections.emptyMap(); diff --git a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java index ca52c697..8cd2e275 100644 --- a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java @@ -41,6 +41,7 @@ import org.slf4j.LoggerFactory; import org.testng.Assert; import org.testng.annotations.Test; +import org.killbill.billing.payment.api.PluginProperty; public class TestAdyenPaymentPluginApi extends TestBase { @@ -269,5 +270,63 @@ private List syncPaymentMethods(UUID kbAccountId) throws PaymentPluginApiException { return adyenPaymentPluginApi.getPaymentMethods(kbAccountId, true, ImmutableList.of(), context); } + + // Fails before the change: the token passed at creation was ignored. + @Test(groups = "integration") + public void testAddPaymentMethodWithExternalToken() throws Exception { + final UUID kbPaymentMethodId = UUID.randomUUID(); + addPaymentMethod(kbPaymentMethodId, ImmutableList.of( + new PluginProperty("recurringDetailReference", "TOKEN_FROM_MERCHANT", false), + new PluginProperty("shopperReference", "customer-42", false))); + + final AdyenPaymentMethodsRecord record = dao.getPaymentMethod(kbPaymentMethodId.toString()); + Assert.assertEquals(record.getRecurringDetailReference(), "TOKEN_FROM_MERCHANT"); + } + + // Regression: without the property nothing is stored (old behaviour). + @Test(groups = "integration") + public void testAddPaymentMethodWithoutToken() throws Exception { + final UUID kbPaymentMethodId = UUID.randomUUID(); + addPaymentMethod(kbPaymentMethodId, ImmutableList.of( + new PluginProperty("enableRecurring", "true", false))); + + Assert.assertNull(dao.getPaymentMethod(kbPaymentMethodId.toString()).getRecurringDetailReference()); + } + + @Test(groups = "integration") + public void testShopperReferenceSelection() throws Exception { + final UUID kbAccountId = account.getId(); + + // 1. token + reference given together -> the given reference + final UUID pm1 = UUID.randomUUID(); + addPaymentMethod(pm1, ImmutableList.of( + new PluginProperty("recurringDetailReference", "TOKEN_1", false), + new PluginProperty("shopperReference", "customer-42", false))); + Assert.assertEquals(adyenPaymentPluginApi.getShopperReference( + dao.getPaymentMethod(pm1.toString()), kbAccountId), "customer-42"); + + // 2. reference given, token stored later by a webhook -> Kill Bill account id + final UUID pm2 = UUID.randomUUID(); + addPaymentMethod(pm2, ImmutableList.of( + new PluginProperty("enableRecurring", "true", false), + new PluginProperty("shopperReference", "customer-42", false))); + dao.updateRecurringDetailsPaymentMethod(pm2, context.getTenantId(), "TOKEN_FROM_WEBHOOK"); + Assert.assertEquals(adyenPaymentPluginApi.getShopperReference( + dao.getPaymentMethod(pm2.toString()), kbAccountId), kbAccountId.toString()); + + // 3. nothing given (existing payment methods) -> Kill Bill account id + final UUID pm3 = UUID.randomUUID(); + addPaymentMethod(pm3, ImmutableList.of(new PluginProperty("enableRecurring", "true", false))); + dao.updateRecurringDetailsPaymentMethod(pm3, context.getTenantId(), "TOKEN_FROM_WEBHOOK"); + Assert.assertEquals(adyenPaymentPluginApi.getShopperReference( + dao.getPaymentMethod(pm3.toString()), kbAccountId), kbAccountId.toString()); + } + + private void addPaymentMethod(final UUID kbPaymentMethodId, final List props) + throws PaymentPluginApiException { + adyenPaymentPluginApi.addPaymentMethod(account.getId(), kbPaymentMethodId, + new AdyenPaymentMethodPlugin(kbPaymentMethodId, kbPaymentMethodId.toString(), true, props), + true, ImmutableList.of(), context); + } } From 358b48d340d065fc2e8c4cf1188d12e0f7d51b94 Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:13:42 +0530 Subject: [PATCH 3/7] Document recurringDetailReference and shopperReference properties --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 2da8752e..1192d3c1 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,28 @@ Note that this creates a payment in Kill Bill for the specified amount in `PENDI 7. If the payment is successful, Adyen sends a notification to Kill Bill; the plugin stores the recurring token (if `enableRecurring` was set) and moves the payment from `PENDING` to `SUCCESS`. Payments initiated by Kill Bill afterwards (for example for subscription invoices) use the stored token automatically. +## Payment methods tokenised outside the plugin +If the card is tokenised in your own Adyen `/sessions` or `/payments` call (for example before the +Kill Bill account exists), pass the token when creating the Kill Bill payment method: + +* `recurringDetailReference`: the Adyen token (`storedPaymentMethodId`) +* `shopperReference`: the `shopperReference` used when Adyen stored the token + +```json +"pluginInfo": { + "properties": [ + { "key": "recurringDetailReference", "value": "K4C5QX3VQL9C5G65" }, + { "key": "shopperReference", "value": "customer-42" } + ] +} +``` + +The payment method is marked as recurring and payments initiated by Kill Bill (for example for +subscription invoices) use the token right away; no first payment through `/checkout` is needed. +Adyen only accepts a token together with the `shopperReference` it was stored under: without +`shopperReference`, the plugin sends the Kill Bill account id and Adyen refuses the payment +(`800 Contract not found`). ## Credits From c1f130013ba5f85c6bc266debf0167a34c3d8594 Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:29:32 +0530 Subject: [PATCH 4/7] Trim stored Adyen token read from char(36) column (PostgreSQL pads it) --- .../adyen/api/AdyenPaymentPluginApi.java | 38 +++++++++++-------- .../adyen/TestAdyenPaymentPluginApi.java | 3 +- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java index ca26bb48..3a004217 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java @@ -350,7 +350,7 @@ public PaymentTransactionInfoPlugin purchasePayment( formFields.add( new PluginProperty(SESSION_DATA, outputDTO.getAdditionalData().get(SESSION_DATA), false)); } else { - input.setRecurringData(paymentMethodRecord.getRecurringDetailReference()); + input.setRecurringData(getStoredToken(paymentMethodRecord)); // Adyen only accepts a stored token with the shopperReference it was stored under. For // payment methods tokenised outside the plugin, that reference was given at creation; // otherwise the plugin stored the token under the Kill Bill account id. @@ -669,20 +669,28 @@ private void refreshPaymentInKillBill(final UUID kbPaymentId, final CallContext @VisibleForTesting public String getShopperReference( - final AdyenPaymentMethodsRecord paymentMethodRecord, final UUID kbAccountId) { - final Map data = getAdditionalDataMap(paymentMethodRecord.getAdditionalData()); - final Object shopperReference = data.get(SHOPPER_REFERENCE); - final Object tokenAtCreation = data.get(RECURRING_DETAIL_REFERENCE); - // Use the given reference only for the token it was given with; a token stored later by a - // plugin-initiated payment is always stored under the Kill Bill account id. - if (shopperReference != null - && !shopperReference.toString().trim().isEmpty() - && tokenAtCreation != null - && tokenAtCreation.toString().trim().equals(paymentMethodRecord.getRecurringDetailReference())) { - return shopperReference.toString().trim(); - } - return kbAccountId.toString(); - } + final AdyenPaymentMethodsRecord paymentMethodRecord, final UUID kbAccountId) { + final Map data = getAdditionalDataMap(paymentMethodRecord.getAdditionalData()); + final Object shopperReference = data.get(SHOPPER_REFERENCE); + final Object tokenAtCreation = data.get(RECURRING_DETAIL_REFERENCE); + final String storedToken = getStoredToken(paymentMethodRecord); + // Use the given reference only for the token it was given with; a token stored later by a + // plugin-initiated payment is always stored under the Kill Bill account id. + if (shopperReference != null + && !shopperReference.toString().trim().isEmpty() + && tokenAtCreation != null + && tokenAtCreation.toString().trim().equals(storedToken)) { + return shopperReference.toString().trim(); + } + return kbAccountId.toString(); + } + + // recurring_detail_reference is a char(36) column: PostgreSQL returns it right-padded with + // spaces (MySQL strips them), so trim before sending it to Adyen or comparing it. + private static String getStoredToken(final AdyenPaymentMethodsRecord paymentMethodRecord) { + final String token = paymentMethodRecord.getRecurringDetailReference(); + return token == null ? null : token.trim(); + } public Map getAdditionalDataMap(String additionalData) { if (additionalData == null) { diff --git a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java index 8cd2e275..b3ea6757 100644 --- a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java @@ -280,7 +280,8 @@ public void testAddPaymentMethodWithExternalToken() throws Exception { new PluginProperty("shopperReference", "customer-42", false))); final AdyenPaymentMethodsRecord record = dao.getPaymentMethod(kbPaymentMethodId.toString()); - Assert.assertEquals(record.getRecurringDetailReference(), "TOKEN_FROM_MERCHANT"); + // char(36) column: PostgreSQL returns the value right-padded with spaces + Assert.assertEquals(record.getRecurringDetailReference().trim(), "TOKEN_FROM_MERCHANT"); } // Regression: without the property nothing is stored (old behaviour). From 4f3655910c162132fabde26509fcc2adc40c8f57 Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:22:25 +0530 Subject: [PATCH 5/7] Store external token in the payment method insert; fix PostgreSQL DDL instructions --- README.md | 4 ++-- .../adyen/api/AdyenPaymentPluginApi.java | 12 +++++----- .../billing/plugin/adyen/dao/AdyenDao.java | 9 +++++--- .../adyen/TestAdyenPaymentPluginApi.java | 22 +++++++++++++++++++ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1192d3c1..7d0fbefb 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Version `0.8.0` of the plugin uses Adyen classic integration while version `0.9. The plugin needs a database and does not create its tables automatically. Before starting the plugin for the first time, run the respective DDL on the Kill Bill database: -* MySQL: [ddl.sql](https://github.com/killbill/killbill-adyen-plugin/blob/master/src/main/resources/ddl.sql) -* PostgreSQL: [ddl-postgresql.sql](https://github.com/killbill/killbill-adyen-plugin/blob/master/src/main/resources/ddl-postgresql.sql) +* MySQL: run [ddl.sql](src/main/resources/ddl.sql). +* PostgreSQL: run [ddl-postgresql.sql](src/main/resources/ddl-postgresql.sql) first to create the required domains, then run [ddl.sql](src/main/resources/ddl.sql) to create the tables. ## Build diff --git a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java index 3a004217..8a726296 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java @@ -226,19 +226,17 @@ public void addPaymentMethod( recurring = false; } try { + // A payment method tokenised outside the plugin gets its token in the same insert, instead + // of waiting for an AUTHORISATION notification of a plugin-initiated payment. A single + // write means a failed creation leaves no row behind, so it can be retried with the same id. this.adyenDao.addPaymentMethod( kbAccountId, kbPaymentMethodId, mergedProperties, recurring, context.getTenantId(), - setDefault); - if (hasToken) { - // The payment method was tokenised outside the plugin: store the token now instead of - // waiting for an AUTHORISATION notification of a plugin-initiated payment. - this.adyenDao.updateRecurringDetailsPaymentMethod( - kbPaymentMethodId, context.getTenantId(), recurringDetailReference.trim()); - } + setDefault, + hasToken ? recurringDetailReference.trim() : null); } catch (SQLException e) { throw new PaymentMethodException("[addPaymentMethod] Error inserting payment method", e); diff --git a/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java b/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java index c693f96b..1ee792e1 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/dao/AdyenDao.java @@ -66,7 +66,8 @@ public void addPaymentMethod( final Map additionalDataMap, final boolean isRecurring, final UUID kbTenantId, - final boolean setDefault) + final boolean setDefault, + @Nullable final String recurringDetailReference) throws SQLException { final Map clonedProperties = new HashMap<>(additionalDataMap); @@ -87,7 +88,8 @@ public AdyenResponsesRecord withConnection(final Connection conn) throws SQLExce ADYEN_PAYMENT_METHODS.CREATED_DATE, ADYEN_PAYMENT_METHODS.UPDATED_DATE, ADYEN_PAYMENT_METHODS.IS_DEFAULT, - ADYEN_PAYMENT_METHODS.KB_TENANT_ID) + ADYEN_PAYMENT_METHODS.KB_TENANT_ID, + ADYEN_PAYMENT_METHODS.RECURRING_DETAIL_REFERENCE) .values( kbAccountId.toString(), kbPaymentMethodId.toString(), @@ -97,7 +99,8 @@ public AdyenResponsesRecord withConnection(final Connection conn) throws SQLExce toLocalDateTime(new DateTime()), toLocalDateTime(new DateTime()), (short) fromBoolean(setDefault), - kbTenantId.toString()) + kbTenantId.toString(), + recurringDetailReference) .execute(); return null; diff --git a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java index b3ea6757..ac52abe1 100644 --- a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java @@ -284,6 +284,28 @@ public void testAddPaymentMethodWithExternalToken() throws Exception { Assert.assertEquals(record.getRecurringDetailReference().trim(), "TOKEN_FROM_MERCHANT"); } + // A failed creation must not leave a row behind: the retry with the same id has to succeed. + @Test(groups = "integration") + public void testAddPaymentMethodWithInvalidTokenLeavesNoRow() throws Exception { + final UUID kbPaymentMethodId = UUID.randomUUID(); + // 41 characters: longer than the char(36) recurring_detail_reference column + final String tooLongToken = "TOKEN_FROM_MERCHANT_0123456789_0123456789"; + try { + addPaymentMethod(kbPaymentMethodId, ImmutableList.of( + new PluginProperty("recurringDetailReference", tooLongToken, false))); + Assert.fail("Expected the insert to fail for a token longer than the column"); + } catch (final RuntimeException expected) { + // expected + } + Assert.assertNull(dao.getPaymentMethod(kbPaymentMethodId.toString())); + + addPaymentMethod(kbPaymentMethodId, ImmutableList.of( + new PluginProperty("recurringDetailReference", "TOKEN_FROM_MERCHANT", false))); + Assert.assertEquals( + dao.getPaymentMethod(kbPaymentMethodId.toString()).getRecurringDetailReference().trim(), + "TOKEN_FROM_MERCHANT"); + } + // Regression: without the property nothing is stored (old behaviour). @Test(groups = "integration") public void testAddPaymentMethodWithoutToken() throws Exception { From 34f7c1fe4a64d12d6faad430e5c267c89561faf1 Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:49:20 +0530 Subject: [PATCH 6/7] Keep shopperReference as given; README: isDefault query parameter and doc fixes --- README.md | 18 +++++++++--------- .../adyen/api/AdyenPaymentPluginApi.java | 3 ++- .../adyen/TestAdyenPaymentPluginApi.java | 17 ++++++++++------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7d0fbefb..0ce0b811 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ Where: * returnUrl: URL the shopper is sent back to after the Drop-in payment (passed to Adyen's `/sessions` as `returnUrl`). This is your application's URL, not the notification URL * merchantAccount: Merchant account created at step 2 * hcmaKey: HMAC Key generated at step 4a -* captureDelayHours: Desire capture delay in hours after Authorize , number must be between 0 - 168 hr -* environment: Environment to use. Possible values are `TEST`/`LIVE`. default value is `TEST` +* captureDelayHours: desired capture delay in hours after authorisation, between 0 and 168 +* enviroment (spelled this way in the property name): environment to use. Possible values are `TEST`/`LIVE`. The default value is `TEST` * username / password: a Kill Bill user login (for example `admin` / `password`). The `/checkout` servlet uses these to authenticate against Kill Bill when it creates the first payment. They are not the webhook Basic Authentication credentials from step 4b; the plugin does not check those ## Payment status after an Adyen notification @@ -149,7 +149,7 @@ The following steps need to be followed in order to use the Adyen plugin: 2. [Create](https://killbill.github.io/slate/?shell#account-create-an-account) a Kill Bill account and save the `accountId` for further use. -3. Create a Kill Bill Payment Method. Specify a `PluginProperty` corresponding to `enableRecurring` if this is going to be a recurring payment (the default value of this property is `false`). Set `isDefault` to `true` if Kill Bill should use this payment method to pay invoices automatically: +3. Create a Kill Bill Payment Method. Specify a `PluginProperty` corresponding to `enableRecurring` if this is going to be a recurring payment (the default value of this property is `false`). Add the `isDefault=true` query parameter if Kill Bill should use this payment method to pay invoices automatically: ```bash curl -v \ @@ -163,11 +163,9 @@ curl -v \ -H "X-Killbill-Reason: demo" \ -H "X-Killbill-Comment: demo" \ -d '{ - "accountId": "2ad52f53-85ae-408a-9879-32a7e59dd03d", + "accountId": "8785164f-b5d7-4da1-9495-33f5105e8d80", "pluginName": "adyen-plugin", - "isDefault": true, "pluginInfo": { - "isDefaultPaymentMethod": true, "properties": [ { "key": "enableRecurring", @@ -177,9 +175,9 @@ curl -v \ ] } }' \ - "http://127.0.0.1:8080/1.0/kb/accounts/8785164f-b5d7-4da1-9495-33f5105e8d80/paymentMethods" + "http://127.0.0.1:8080/1.0/kb/accounts/8785164f-b5d7-4da1-9495-33f5105e8d80/paymentMethods?isDefault=true" ``` -4. Call `/plugins/adyen-plugin/checkout` to generate a session and create the first payment (Note that the amount needs needs to be specified in [minor units](https://docs.adyen.com/development-resources/currency-codes)): +4. Call `/plugins/adyen-plugin/checkout` to generate a session and create the first payment (Note that the amount needs to be specified in [minor units](https://docs.adyen.com/development-resources/currency-codes)): ```bash curl -v \ @@ -201,7 +199,7 @@ Note that this creates a payment in Kill Bill for the specified amount in `PENDI 6. Collect customer's payment details via the drop-in. -7. If the payment is successful, Adyen sends a notification to Kill Bill; the plugin stores the recurring token (if `enableRecurring` was set) and moves the payment from `PENDING` to `SUCCESS`. Payments initiated by Kill Bill afterwards (for example for subscription invoices) use the stored token automatically. +7. If the payment is successful, Adyen sends a notification to Kill Bill; the plugin stores the recurring token (if `enableRecurring` was set) and moves the payment from `PENDING` to `SUCCESS`. Payments initiated by Kill Bill afterwards (for example for subscription invoices) use the stored token automatically, provided the payment method is the account's default (see step 3). ## Payment methods tokenised outside the plugin @@ -222,6 +220,8 @@ Kill Bill account exists), pass the token when creating the Kill Bill payment me The payment method is marked as recurring and payments initiated by Kill Bill (for example for subscription invoices) use the token right away; no first payment through `/checkout` is needed. +To have Kill Bill pay invoices with it automatically, create it as the account's default payment +method (`POST /1.0/kb/accounts/{accountId}/paymentMethods?isDefault=true`). Adyen only accepts a token together with the `shopperReference` it was stored under: without `shopperReference`, the plugin sends the Kill Bill account id and Adyen refuses the payment (`800 Contract not found`). diff --git a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java index 8a726296..cd692388 100644 --- a/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/adyen/api/AdyenPaymentPluginApi.java @@ -678,7 +678,8 @@ public String getShopperReference( && !shopperReference.toString().trim().isEmpty() && tokenAtCreation != null && tokenAtCreation.toString().trim().equals(storedToken)) { - return shopperReference.toString().trim(); + // Opaque key Adyen stored the token under: return it exactly as given (no trim). + return shopperReference.toString(); } return kbAccountId.toString(); } diff --git a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java index ac52abe1..40ba0046 100644 --- a/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/adyen/TestAdyenPaymentPluginApi.java @@ -290,13 +290,8 @@ public void testAddPaymentMethodWithInvalidTokenLeavesNoRow() throws Exception { final UUID kbPaymentMethodId = UUID.randomUUID(); // 41 characters: longer than the char(36) recurring_detail_reference column final String tooLongToken = "TOKEN_FROM_MERCHANT_0123456789_0123456789"; - try { - addPaymentMethod(kbPaymentMethodId, ImmutableList.of( - new PluginProperty("recurringDetailReference", tooLongToken, false))); - Assert.fail("Expected the insert to fail for a token longer than the column"); - } catch (final RuntimeException expected) { - // expected - } + Assert.expectThrows(RuntimeException.class, () -> addPaymentMethod(kbPaymentMethodId, + ImmutableList.of(new PluginProperty("recurringDetailReference", tooLongToken, false)))); Assert.assertNull(dao.getPaymentMethod(kbPaymentMethodId.toString())); addPaymentMethod(kbPaymentMethodId, ImmutableList.of( @@ -343,6 +338,14 @@ public void testShopperReferenceSelection() throws Exception { dao.updateRecurringDetailsPaymentMethod(pm3, context.getTenantId(), "TOKEN_FROM_WEBHOOK"); Assert.assertEquals(adyenPaymentPluginApi.getShopperReference( dao.getPaymentMethod(pm3.toString()), kbAccountId), kbAccountId.toString()); + + // 4. the given reference is returned exactly as given (it is an opaque Adyen key) + final UUID pm4 = UUID.randomUUID(); + addPaymentMethod(pm4, ImmutableList.of( + new PluginProperty("recurringDetailReference", "TOKEN_4", false), + new PluginProperty("shopperReference", "customer-43 ", false))); + Assert.assertEquals(adyenPaymentPluginApi.getShopperReference( + dao.getPaymentMethod(pm4.toString()), kbAccountId), "customer-43 "); } private void addPaymentMethod(final UUID kbPaymentMethodId, final List props) From 9c576661c8a99c054385586626ae2cb1bc9d30ef Mon Sep 17 00:00:00 2001 From: KBbitsP <75751774+KBbitsP@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:51:16 +0530 Subject: [PATCH 7/7] README: withPluginInfo only reconciles notifications the plugin already processed --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ce0b811..af221e49 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Since Adyen uses webhooks, the `http:///plugins/adyen-plugin/notificati 2. Test the application using the [Kill Bill Adyen Demo](https://github.com/killbill/killbill-adyen-demo) as explained [here](https://github.com/killbill/killbill-adyen-demo/#test). 3. Verify that a new account is created in Kill Bill with a payment in `PENDING` status. 4. If all goes well, Adyen would then send a notification and the plugin converts the `PENDING` payment status to `SUCCESS` in Kill Bill. -5. Retrieve the payment as follows (`withPluginInfo=true` also triggers the [on-the-fly Janitor](https://docs.killbill.io/latest/userguide_payment.html#_on_the_fly_janitor), which is useful if the notification was not received): +5. Retrieve the payment as follows. `withPluginInfo=true` also triggers the [on-the-fly Janitor](https://docs.killbill.io/latest/userguide_payment.html#_on_the_fly_janitor), which updates the payment status in Kill Bill if the plugin has processed the notification but the immediate update in Kill Bill failed. It does not query Adyen: if the notification never reached the plugin, the payment stays `PENDING` until Adyen delivers it again (Adyen retries webhooks it could not deliver, and they can be resent from the Customer Area): ``` curl \