---
updatedAt: 2026-08-19T14:07:51.000Z
agentTools:
  projectIndex: https://developer.eftcorp.com/llms.txt
---

# Eclipse Plugin Developer Guide

Plugins extend the Eclipse platform at runtime, without rebuilding or redeploying any service.
A plugin is an ordinary JAR uploaded through the Plugin API. The runtime scans it, loads it in an
isolated, security-restricted class loader, wires it into CDI-like injection, and hot-reloads it
within \~10 seconds — no restart.

***

## 1. Plugin categories

There are five extension mechanisms:

| Category                    | What you write                                                                                                              | Wired in by                                                                          |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Injection-point plugins** | A bean with `@OnNamedInjectionPoint` methods                                                                                | Method annotation; auto-discovered                                                   |
| **Implementation plugins**  | A class extending a `Pluggable*` base (wallet type, transfer pipeline, gateway, processor, fraud handler, ratify processor) | `implementationClass` config / `transferPipeline.<regex>` config / type registration |
| **Provider plugins**        | A bean annotated `@ExtensionProvider(type=…, name=…)`                                                                       | The annotation; resolved by `(type, name)`                                           |
| **Batch plugins**           | A bean implementing `ILocalBatchJob`                                                                                        | Auto-registered as a scheduled local batch                                           |
| **Wallet listener plugins** | A class extending `PluggableWalletListener` (wallet service only)                                                           | `setSuperClass()` delegation                                                         |

A single JAR may contain any mix of these. It must contain **exactly one** class annotated with
`@PluginDescriptor`.

***

## 2. Core annotations

All three annotations live in `com.ukheshe.arch.plugin`.

### `@PluginDescriptor` — required, class-level

```java
@Retention(RUNTIME) @Target(TYPE)
public @interface PluginDescriptor {
    String name();                    // required, unique across all loaded plugins
    String version() default "1.0";
    String description() default "";
}
```

Exactly one class per JAR carries it (loading fails with *"more than one PluginDescriptor"* if two
do, or *"no class annotated with @PluginDescriptor"* if none). It is usually an empty marker class.
`name()` is the plugin's identity: re-uploading a JAR with the same `name` **upgrades** the existing
plugin in place (config and tenant mappings are preserved/merged).

### `@OnNamedInjectionPoint` — method-level

```java
@Retention(RUNTIME) @Target(METHOD)
public @interface OnNamedInjectionPoint {
    String injectionPoint();          // e.g. "wallet.postWalletTransfer"
    int priority() default 0;         // higher runs first
    boolean throwExceptions() default false;
}
```

* `priority` — handlers for the same injection point run in **descending** priority order
  (ties keep registration order).
* `throwExceptions` — by default a handler that throws is *swallowed*: the exception is
  recorded in the result and reported via `UK.Exceptions.processException(...)`, and the remaining
  handlers still run. Set `throwExceptions = true` to make the throw **propagate to the caller** and
  abort the operation. This is how *validator* plugins veto an action.
* The method must be **public** and **non-static** (validated at load; otherwise the handler is
  skipped with an error).

### `@ExtensionProvider` — class-level

```java
@Retention(RUNTIME) @Target(TYPE)
public @interface ExtensionProvider {
    String type();
    String name();
}
```

Registers a CDI-scoped plugin bean as a **named provider**. Platform code retrieves it with
`UK.Plugins.getPluginBean(Clazz.class, type, name, tenantId)` or
`getPluginClass(type, name, tenantId)`. The `(type, name)` pair must be unique across all loaded
plugins. Used for tenant-integration processors:

```java
@ApplicationScoped
@ExtensionProvider(type = "TenantIntegrationProcessor", name = "pcb")
public class PcbTenantIntegration implements ITenantIntegrationPlugin {
    public String doGenericIntegration(long tenantId, String postedJson) { … }
}
```

Resolution honours the plugin's tenant mapping and enabled state — `getPluginBean` throws if the
provider isn't on for the requested tenant or has been toggled off.

Provider types the platform resolves today: `TenantIntegrationProcessor`, `HttpRequestFilter`,
`HttpResponseFilter`, `PaymentGateway` and `TransferPipeline` (§8).

***

## 3. Packaging & building a plugin JAR

A plugin is a plain JAR — no `main` class. Plugins build against the `eclipse`**&#x20;parent POM** and
depend on the individual service/model artifacts they touch:

```xml
<parent>
  <groupId>com.ukheshe</groupId>
  <artifactId>eclipse</artifactId>
  <version>5.0.3615</version>   <!-- latest in ~/.m2/repository/com/ukheshe/eclipse/ -->
</parent>
<artifactId>uk-plugin-my-thing</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<dependencies>
  <dependency><groupId>com.ukheshe</groupId><artifactId>uk-services-wallet-service</artifactId></dependency>
  <dependency><groupId>com.ukheshe</groupId><artifactId>uk-services-wallet-model</artifactId></dependency>
  <!-- add fraud / payment / loan / user service+model deps as needed -->
</dependencies>

<build><plugins>
  <plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.14.0</version>
    <configuration><release>25</release></configuration>
  </plugin>
</plugins></build>
```

```bash
cd plugin-<name>
mvn package -DskipTests          # JAR → target/uk-plugin-<name>-1.0.0.jar
```

The platform classpath provides all platform (`com.ukheshe.*`) classes at runtime, so **never bundle
them in your JAR.**

> `plugin-sdk.jar`**&#x20;(lighter alternative).** The repo root contains `plugin-sdk.jar`, built by
> `build-plugin-sdk.sh`. That script scans every `fireInjectionPoint(...)` call site, collects the
> non-JDK context-argument types, and bundles them with the core plugin API. It's a lighter
> compile-time dependency if you only write injection-point handlers and don't want the full service
> jars. Reference it with `provided` scope.

### Class-loader sandbox (important)

Each plugin is loaded by a restricted class loader that **only** resolves:

1. Classes contained in the plugin JAR itself, and
2. Classes in a fixed **whitelist** of packages:
   `java.lang.`, `java.util.`, `java.math.`, `java.time.`, `jakarta.persistence.`,
   `jakarta.ws.rs.`, `jakarta.annotation.`, `jakarta.inject.`, `jakarta.enterprise.context.`,
   `org.slf4j.`, and `com.ukheshe.`

Anything else throws `ClassNotFoundException: Class not on plugins whitelist`. In addition, a
**blacklist** hard-blocks dangerous classes even from the JDK:
`java.lang.ProcessBuilder`, `java.lang.System`, `java.lang.Runtime`, `java.lang.Thread`,
`java.lang.reflect.*`, `java.io.File`, `java.net.Socket`, `java.net.URL`, `java.net.URLClassLoader`,
and everything under `sun.`, `com.sun.`, `jdk.internal.`.

Practical consequences:

* **No reflection, threads, file I/O, sockets, process spawning, or arbitrary networking** from
  plugin code. Use the injected REST clients / framework facades instead.
* A bundled third-party library will load only if *its* transitive class references stay inside the
  whitelist — most won't. Keep plugins to platform-provided capabilities.
* Each plugin's class loader is keyed by a SHA-256 of the JAR. Re-uploading identical bytes is a
  no-op; changed bytes trigger an unload + reload.

### Instantiation contract

* Every plugin bean needs a **visible no-arg constructor** (*"needs a no-arg constructor"* otherwise).
* After construction the framework sets `@Inject` fields (see §5), then invokes any `@PostConstruct`
  method.

***

## 4. CDI scopes & lifecycle

A class is treated as a plugin bean if annotated with one of `@Singleton`, `@ApplicationScoped`,
`@RequestScoped`, or `@Dependent`. The scope controls instance lifetime:

| Scope                               | Instance lifetime                                                              |
| ----------------------------------- | ------------------------------------------------------------------------------ |
| `@Singleton` / `@ApplicationScoped` | One instance for the whole time the plugin is loaded (created eagerly at load) |
| `@RequestScoped`                    | One instance per request context (stored on the `RequestContext`)              |
| `@Dependent`                        | A fresh instance on every use                                                  |

Injection-point handler classes are typically `@Singleton`. Wallet types and gateways are typically
`@Dependent` (a new delegating instance per transaction). Every pluggable bean is also recorded in
the `beans` section of the plugin config (see §11) so it can be individually toggled.

***

## 5. `@Inject` support

Field `@Inject` is supported for a fixed, whitelisted set of types, resolved lazily at instantiation:

| Injectable type                                    | What you get                                                 |
| -------------------------------------------------- | ------------------------------------------------------------ |
| `jakarta.persistence.EntityManager`                | The runtime JPA entity manager                               |
| `com.ukheshe.arch.rest.RequestContext`             | Current request metadata (tenant, user, session, properties) |
| `com.ukheshe.arch.rest.CallAsUserJsonRestClient`   | REST client acting as the current user                       |
| `com.ukheshe.arch.rest.CallAsSystemJsonRestClient` | REST client acting as the system                             |
| `com.ukheshe.arch.rest.AdvancedJsonRestClient`     | Lower-level REST client                                      |
| `com.ukheshe.arch.plugin.PluginConfig`             | This plugin's live config (see §11)                          |

```java
@Singleton
public class MyHandlers {
    @Inject EntityManager em;
    @Inject RequestContext ctx;
    @Inject PluginConfig config;
    …
}
```

* Injection happens **before** `@PostConstruct`, so post-construct code can use the fields.
* An `@Inject` field of **any other type fails loudly** at load: *"is @Inject but type \[…] is not
  injectable into plugins"*. You get an error, not a silent `null`.
* These same services are also resolvable as **handler method parameters** (see §6) — you can take
  them as arguments instead of injecting fields.

***

## 6. Injection-point plugins

### Writing a handler

```java
@PluginDescriptor(name = "my-plugin", version = "1.0", description = "Example")
public class MyPluginInfo { }            // marker

@Singleton
public class MyHandlers {

    @OnNamedInjectionPoint(injectionPoint = "wallet.postWalletTransfer", priority = 5)
    public void onTransfer(@Named("from") IWallet from,
                           @Named("to")   IWallet to,
                           @Named("history") List<WalletHistory> history) {
        // injection points fire for ALL wallets/tenants — filter as needed:
        if (!(from instanceof MyWalletType)) return;
        …
    }
}
```

### Parameter resolution

For each method parameter the framework resolves an argument as follows:

1. `@Named("x")`**&#x20;parameter** → looked up **by name** among the `NamedParam`s passed at the
   injection point. If the name isn't present, it falls back to resolving **by type but only if the
   type is unambiguous** (exactly one instance of that type is available). Otherwise a clear
   `IllegalStateException` is thrown telling you to pass `NamedParam.of("x", value)`.
2. **Un-named parameter** → resolved **by type**: exact match, then assignable match, then the
   global service context (the injectable services from §5). Unresolvable → `IllegalStateException`.

**JSON coercion** — when a `@Named` value's runtime type doesn't match the declared parameter type,
the framework serialises it to JSON and deserialises into the requested type. This lets a plugin
declare **its own DTO classes** (or `String`, `Map`, `List`, or a `JsonPathDocument`) for a parameter
and receive a converted copy, instead of depending on the exact platform model class. If the raw
value is already a `String` it is treated as the JSON payload directly.

**Duplicate-type guard** — if an injection point passes the **same bare (non-**`NamedParam`**) type more
than once**, the framework **skips all handlers for that injection point** and logs a warning.
Platform code wraps such arguments in `NamedParam` precisely to avoid this; your handler then
distinguishes them with `@Named`.

### Return values — `PluginResults`

`UK.Plugins.fireInjectionPoint(point, tenantId, ctx…)` returns a `PluginResults` collecting one
`PluginResult{descriptor, pluginClass, pluginMethod, response, error}` per handler. Most injection
points ignore the result (fire-and-forget listeners). A handful **consume** it:

| Helper                                                   | Meaning                                                      |
| -------------------------------------------------------- | ------------------------------------------------------------ |
| `getFirstNonNullPluginResponse()`                        | First non-null returned value (as `Optional`)                |
| `getFirstString()/getFirstBigDecimal()/getFirstLong()/…` | Typed convenience accessors                                  |
| `getAllNonNullPluginResponses()`                         | All non-null returns, priority order                         |
| `throwFirstException()` / `throwOrElse()`                | Re-throw the first handler error (used by `sendTextSms`, FX) |

A handler that **returns a value** can supply data the platform uses (FX rate, SMS provider id, an
OSS suppression flag). See §7.

***

## 7. Injection-point catalog

Parameters list the `@Named` key where one is used, otherwise the type. **Most injection points will only fire if they are tenant specific and the plugin is mapped to that tenant. If the injection point is not tenant specific then the plugin must be global in order to be fired.**.

### Wallet — `PluggableWalletListener`

| Injection point                 | When                      | Params                                                                                                    |
| ------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
| `wallet.preWalletCreate`        | before wallet persisted   | `Wallet` (newWallet)                                                                                      |
| `wallet.postWalletCreate`       | after wallet persisted    | `Wallet` (newWallet)                                                                                      |
| `wallet.onWalletUpdate`         | wallet updated            | `Wallet`                                                                                                  |
| `wallet.preReservation`         | before a reservation      | `BigDecimal` amount, `@Named sessionId`, `ZonedDateTime` expiryDate, `@Named description`, `@Named force` |
| `wallet.preWalletTransfer`      | before transfer           | `@Named from` (IWallet), `@Named to` (IWallet), `@Named bulk`, `@Named scheduled`                         |
| `wallet.postWalletTransfer`     | after transfer            | `@Named from`, `@Named to`, `@Named history` (List\<WalletHistory>), `@Named bulk`, `@Named scheduled`    |
| `wallet.augmentTransfer`        | augment/split a transfer  | `@Named from`, `@Named to`, `@Named bulk`, `@Named scheduled`                                             |
| `wallet.doSMSForFailedTransfer` | SMS for failed transfer   | `Transfer`, `@Named from`, `@Named to`, `String` failedReason                                             |
| `wallet.onTransactionFailure`   | ledger-level failure      | `@Named walletId`, `@Named otherWalletId`, `BigDecimal` amount, `@Named currency`, `@Named sessionId`     |
| `wallet.postWalletTypeCreate`   | after wallet type created | `WalletType`                                                                                              |
| `wallet.onWalletTypeUpdate`     | wallet type updated       | `WalletType`, `DbWalletType` (existing)                                                                   |

### Payment — `PluggablePaymentListener`

| Injection point                  | When                         | Params       |
| -------------------------------- | ---------------------------- | ------------ |
| `payment.preCreatePayment`       | before payment persisted     | `NewPayment` |
| `payment.postCreatePayment`      | after payment persisted      | `Payment`    |
| `payment.preCreateWithdrawal`    | before withdrawal persisted  | `Withdrawal` |
| `payment.postCreateWithdrawal`   | after withdrawal persisted   | `Withdrawal` |
| `payment.postPaymentComplete`    | payment reaches completed    | `Payment`    |
| `payment.postWithdrawalComplete` | withdrawal reaches completed | `Withdrawal` |
| `payment.prePaymentAcquiring`    | before acquiring             | `DbPayment`  |

### Loan — `PluggableLoanListener`

| Injection point       | When                  | Params                      |
| --------------------- | --------------------- | --------------------------- |
| `loan.preCreateLoan`  | before loan persisted | `NewLoan`                   |
| `loan.postCreateLoan` | after loan persisted  | `Loan`                      |
| `loan.preLoanUpdate`  | before loan updated   | `Loan`, `DbLoan` (existing) |
| `loan.postLoanUpdate` | after loan updated    | `Loan`                      |

### Remittance — `PluggableRemittanceListener`

| Injection point                     | When                        | Params                                  |
| ----------------------------------- | --------------------------- | --------------------------------------- |
| `remittance.preCreateRemittance`    | before remittance persisted | `NewRemittance`                         |
| `remittance.postCreateRemittance`   | after remittance persisted  | `Remittance`                            |
| `remittance.preRemittanceUpdate`    | before remittance updated   | `Remittance`, `DbRemittance` (existing) |
| `remittance.postRemittanceUpdate`   | after remittance updated    | `Remittance`                            |
| `remittance.postRemittanceComplete` | remittance completed        | `Remittance`                            |
| `remittance.preRemittanceAcquiring` | before acquiring            | `Remittance`                            |

### User & organisation — `PluggableUserListener`

Credentials are always stripped before these fire (passwords, secrets, key/info).

| Injection point                                                   | When                       | Params                                                                      |
| ----------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
| `user.onUserCreate`                                               | before user create         | `User` (newUser)                                                            |
| `user.onUserCreated`                                              | after user create          | `User`                                                                      |
| `user.onUserUpdate`                                               | before user update         | `User` (changes), `DbUser` (existing)                                       |
| `user.onUserUpdated`                                              | after user update          | `User`                                                                      |
| `user.onUserDelete`                                               | user deleted               | `User`                                                                      |
| `user.onOrganisationCreate`                                       | before org create          | `Organisation` (new)                                                        |
| `user.onOrganisationCreated`                                      | after org create           | `Organisation`                                                              |
| `user.onOrganisationUpdate`                                       | before org update          | `Organisation` (changes), `DbOrganisation` (existing)                       |
| `user.onOrganisationUpdated`                                      | after org update           | `Organisation`                                                              |
| `user.onOrganisationDelete`                                       | org deleted                | `Organisation`                                                              |
| `user.onUserIdentityCreate`                                       | identity created           | `UserIdentity` (sanitised)                                                  |
| `user.onUserIdentityDelete`                                       | identity deleted           | `UserIdentity`                                                              |
| `user.preAuth`                                                    | before authentication      | `AuthCredentials` (password stripped)                                       |
| `user.onAuthSuccess`                                              | auth succeeded             | `AuthCredentials`, `User`                                                   |
| `user.onAuthFailure`                                              | auth failed                | `AuthCredentials`, `DbUser`                                                 |
| `user.onDocumentCreate` / `onDocumentUpdate` / `onDocumentDelete` | document lifecycle         | `Document` (binary stripped)                                                |
| `user.onAddressCreate`                                            | address created            | `Address`                                                                   |
| `user.onAddressUpdate`                                            | before address update      | `Address` (userAddress), `DbAddress` (existing)                             |
| `user.onAddressUpdated`                                           | after address update       | `Address`                                                                   |
| `user.onAddressDelete`                                            | address deleted            | `Address`                                                                   |
| `user.onUserContactCreate`                                        | contact created            | `UserContact`                                                               |
| `user.onUserContactUpdate`                                        | before contact update      | `UserContact`, `DbUserContact` (existing)                                   |
| `user.onUserContactUpdated`                                       | after contact update       | `UserContact` (updated)                                                     |
| `user.onUserContactDelete`                                        | contact deleted            | `UserContact`                                                               |
| `user.onUserPositionCreate` / `onUserPositionDelete`              | position lifecycle         | `UserPosition`                                                              |
| `user.onUserRoleCreate` / `onUserRoleDelete`                      | role lifecycle             | `UserRole`                                                                  |
| `user.onUserRatified`                                             | user KYC ratified          | `RatifyResult`                                                              |
| `user.onOrganisationRatified`                                     | org KYC ratified           | `RatifyResult`                                                              |
| `user.onManualRatify`                                             | manual ratify submitted    | `String` ratifyResultData                                                   |
| `user.postRatifyBatchCompletion`                                  | ratify batch finished      | `String` queueName, `RatifyBatchType` type, `Long` batchId, `String` checks |
| `user.onPasswordUpdate`                                           | password changed           | `UserIdentityPasswordChange`                                                |
| `user.onUserIdentityPasswordChangeInit`                           | password-change flow start | `PasswordChangeInit`                                                        |

### Attachment — `PluggableAttachmentListener`

| Injection point                  | When                | Params                              |
| -------------------------------- | ------------------- | ----------------------------------- |
| `attachment.onGetAttachments`    | attachments fetched | `List<Attachment>`, `String` fields |
| `attachment.onAttachmentCreated` | after create        | `Attachment`                        |
| `attachment.onAttachmentUpdated` | after update        | `Attachment`                        |
| `attachment.onAttachmentDeleted` | after delete        | `Attachment`                        |

### Property — `PluggablePropertyListener`

Property is a global resource: `tenantId`**&#x20;is always&#x20;**`null` at these points (global mapping or
explicit tenant mapping required to receive them).

| Injection point               | When         | Params             |
| ----------------------------- | ------------ | ------------------ |
| `property.postPropertyCreate` | after create | `Property`         |
| `property.onPropertyUpdate`   | on update    | `Property`         |
| `property.onPropertyDelete`   | on delete    | `Property`         |
| `property.onPropertyGet`      | on fetch     | `List<DbProperty>` |

### Postilion / card — `PluggablePostilionListener`

| Injection point                     | When                    | Params                                                        |
| ----------------------------------- | ----------------------- | ------------------------------------------------------------- |
| `postilion.onCardCreated`           | card created            | `PostilionCard`                                               |
| `postilion.onCardUpdated`           | card updated            | `PostilionCardUpdate`, `@Named postilionCard` (PostilionCard) |
| `postilion.onStandardLimitsUpdated` | standard limits updated | `PnCardStandardLimit`                                         |
| `postilion.onAdvancedLimitsUpdated` | advanced limits updated | `PnCardAdvancedLimit`                                         |

### Cross-cutting / framework

| Injection point     | When                                  | Params                                                                                                                           | Return used                                            |
| ------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `conductorRequest`  | inbound Conductor request (after AAA) | `ContainerRequestContext`                                                                                                        | — (use `JaxRsUtils.replyImmediately` to short-circuit) |
| `conductorResponse` | outbound Conductor response           | `ContainerRequestContext`, `ContainerResponseContext`                                                                            | —                                                      |
| `domainEvent`       | any domain event published            | `DomainEvent`                                                                                                                    | —                                                      |
| `exception`         | exception processed by handler        | `Throwable`, `List<? extends ExceptionData>`                                                                                     | —                                                      |
| `alert`             | alert sent via `UK.Alerts`            | `ExceptionSeverity` severity, `@Named channelName`, `@Named message`, `ZonedDateTime` alertDate, `Throwable` t, `@Named traceId` | —                                                      |

### Libraries — return-producing points

| Injection point           | When                    | Params                                                                                 | Expected return                                                     |
| ------------------------- | ----------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `convertCurrency`         | spot FX rate needed     | `@Named baseCurrency`, `@Named toCurrency`                                             | `ExchangeRate` (via `getFirstNonNullPluginResponse`)                |
| `convertCurrencyHistoric` | historic FX rate needed | `@Named baseCurrency`, `@Named toCurrency`, `@Named date` (Instant)                    | `ExchangeRate`                                                      |
| `sendTextSms`             | platform sends an SMS   | `@Named cardBin`, `@Named to`, `@Named message`, `@Named senderId`, `@Named rawObject` | `String` provider message id (via `throwOrElse().getFirstString()`) |

If no plugin returns a value, the platform's built-in provider is used.

### Dynamic injection points

Two producers build the injection-point **name at runtime**, so the name isn't a fixed string:

* **State publisher** — `"statePublish." + <uniqueId>` (the state-publisher's configured unique id).
  Params: `@Named resultRow` / `@Named resultSet` (JSON string) and `@Named config`. Fired per-row
  and/or for the whole result set. `tenantId = null`.
* **User OSS action processors** — the injection-point name **is the OSS&#x20;**`action`**&#x20;string** from the
  domain event, e.g. `oss.user.update`, `oss.wallet.create`, `oss.wallet.update`,
  `oss.document.create/update`, `oss.address.create/update`. Param: `DomainEvent`. **Return&#xA;**`Boolean.TRUE`**&#x20;to suppress** the platform's default KYC/processing for that action; any other
  value lets default processing proceed. For the compliance/business rules behind when and why OSS runs, see [Ongoing Screening and Scoring (OSS)](https://developer.eftcorp.com/docs/kyc-use-cases#ongoing-screening-and-scoring-oss) in the KYC Use Cases guide.

***

## 8. Implementation plugins (bidirectional delegation)

Implementation plugins replace or extend a platform component: a **wallet type**, a **payment /
withdrawal / loan / remittance gateway**, or a **payment / withdrawal processor**. They all use the
same *bidirectional delegation* triad, because a plugin loaded at runtime cannot directly subclass a
Quarkus build-time managed bean (`super.method()` would hit an unmanaged instance with `null`
injected fields).

**The triad:**

* `Pluggable*` — a plain POJO implementing the domain interface (`IWallet`, `IPaymentGateway`,
  …). *Your plugin extends this.* It holds a delegate and forwards each interface method to the
  delegate's underscore twin (`method_()`).
* `BiDirectionalDelegating*` — an interface extending the domain interface; declares
  `setSubclass(...)` plus an underscore twin of every method.
* `Base*`**&#x20;/ delegate bean** — `@Dependent @Unremovable`, extends the real managed implementation
  and implements the interface. Non-underscore methods delegate **down** to your subclass; the
  `method_()` twins call the real `super.method()`.

You wire it up in `@PostConstruct`:

```java
@PluginDescriptor(name = "Day Limit Wallet", version = "1.0")
@Dependent
public class DayLimitWallet extends PluggableWallet {
    @PostConstruct void init() {
        setSuperClass(UK.Runtime.getBean(LimitCheckingWallet.class));
    }
    @Override public BigDecimal getAvailableBalance() {
        // your logic; call super.getAvailableBalance() to reach the real implementation
        return super.getAvailableBalance();
    }
}
```

When framework code higher up calls `this.method()`, it lands in the `Base*` bean which routes to
your subclass (overrides win). When you call `super.method()`, it routes through the `Pluggable*`
POJO → delegate's `method_()` → the real implementation. You get true inheritance semantics over a
CDI-managed bean.

### Triads available

| Plugin kind              | Extend                                                        | `setSuperClass(...)` argument                                                                                                   | Domain interface       |
| ------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Wallet type**          | `…wallet.pluggable.PluggableWallet`                           | `LimitCheckingWallet` *(impl of&#x20;*`BiDirectionalDelegatingWallet`*)*                                                        | `IWallet`              |
| **Transfer pipeline**    | `…wallet.pluggable.PluggableTransferPipeline`                 | `GenericTransferPipeline` or `ComplexCrossTenantTransferPipeline` *(impls of&#x20;*`BiDirectionalDelegatingTransferPipeline`*)* | `ITransferPipeline`    |
| **Payment gateway**      | `…payment.gateway.pluggable.PluggablePaymentGateway`          | `BasePaymentGateway` *(*`BiDirectionalDelegatingPaymentGateway`*)*                                                              | `IPaymentGateway`      |
| **Withdrawal gateway**   | `…payment.gateway.pluggable.PluggableWithdrawalGateway`       | `BaseWithdrawalGateway`                                                                                                         | `IWithdrawalGateway`   |
| **Loan gateway**         | `…loan.gateway.pluggable.PluggableLoanGateway`                | `BaseLoanGateway`                                                                                                               | `ILoanGateway`         |
| **Remittance gateway**   | `…remittance.gateway.pluggable.PluggableRemittanceGateway`    | `BaseRemittanceGateway`                                                                                                         | `IRemittanceGateway`   |
| **Payment processor**    | `…conductor.service.payments.PluggablePaymentProcessor`       | `StandardPaymentProcessor` *(direct concrete delegate)*                                                                         | `IPaymentProcessor`    |
| **Withdrawal processor** | `…conductor.service.withdrawals.PluggableWithdrawalProcessor` | `StandardWithdrawalProcessor`                                                                                                   | `IWithdrawalProcessor` |

The two **processors** use a simplified variant: `setSuperClass(...)` takes the concrete
`Standard*Processor` directly and calls `setSubclass(this)` internally; there is no separate
`BiDirectionalDelegating*Processor` interface.

### Wallet type — key overridable methods

`init(...)` is the mandatory bootstrap (sets the subclass and delegates). All other methods have a
working base implementation; override only what you need. Highlights:

`getAvailableBalance()`, `getCurrentBalance()`, `getCreditLimit()`, `getCreditReservations()`,
`getDebitReservations()`,
`deltaBalance(amount, uniqueId, type, skipLimitChecks, isReversal, isReallocation)`,
`reserveForTransfer(amount, currency, sessionId, expiryDate, description, uniqueId, force, cardId, info, onlyCheck)`,
`reserveForServiceCharging(units, ReservationRequest)`, `chargeForService(units, ChargeRequest)`,
`getWalletLimits(limitConfigName)`, `getAllConfig()`, `getConfigValue(name)`, and the currency
conversion helpers.

### Transfer pipeline — key overridable methods

A transfer pipeline is the code that moves value out of a wallet: it validates the transfer, debits
the source, credits the destination and writes the wallet history rows. A pipeline plugin extends an
existing pipeline for **selected transfer types on a selected source wallet type** — extra
validation, a cap, an external leg, fee splitting, or stamping the transfer.

Extend `com.ukheshe.services.wallet.pluggable.PluggableTransferPipeline` and nominate the pipeline
you are extending in `@PostConstruct`:

| `setSuperClass(...)` argument                          | Pipeline it runs                                   |
| ------------------------------------------------------ | -------------------------------------------------- |
| `…wallet.pluggable.GenericTransferPipeline`            | The standard single-tenant debit / credit pipeline |
| `…wallet.pluggable.ComplexCrossTenantTransferPipeline` | The standard cross-tenant pipeline (treasury legs) |

```java
@PluginDescriptor(name = "Capped Transfer Pipeline", version = "1.0")
@ExtensionProvider(type = "TransferPipeline", name = "capped-transfer")
@Dependent
public class CappedTransferPipeline extends PluggableTransferPipeline {

    @PostConstruct
    public void init() {
        setSuperClass(UK.Runtime.getBean(GenericTransferPipeline.class));
    }

    @Override
    public List<WalletHistory> go() {
        Transfer transfer = getTransfer();
        String max = getFrom().getConfigValue("maxTransferAmount");
        if (max != null && transfer.getAmount().compareTo(new BigDecimal(max)) > 0) {
            throw new UserInputException("Transfer exceeds the maximum for this wallet");
        }
        putInAvp("cappedPipeline", "true", transfer.getInfo());
        return super.go();          // runs GenericTransferPipeline.go()
    }
}
```

* **`go()`** is the extension point, and the only method routed bidirectionally. Your logic goes
  before and/or after `super.go()`; throwing from it vetoes the debit and rolls the transfer back.
* Read the transfer being run with `getFrom()` (the wallet **being debited**), `getTo()`,
  `getTransfer()`, `isBulk()` and `isScheduled()`; the pipeline helpers `makeTransferStatus(...)`,
  `findInAvp(...)` and `putInAvp(...)` are available too. These are plain calls onto the pipeline you
  extend — plugins do not override them.
* The plugin **must be&#x20;**`@Dependent`. One pipeline instance handles exactly one transfer, and
  `init` throws `IllegalStateException` if an instance is reused.
* AVPs you add to `transfer.getInfo()` before `super.go()` are persisted on the wallet history rows,
  alongside the `pipelineName` AVP the platform stamps with the pipeline class that ran.

### Gateway — key methods

`init(Db…)` bootstraps. Methods that are **empty/stubbed on the&#x20;**`Pluggable*`**&#x20;base must be
implemented by your plugin** (no `_` delegate); the rest delegate to the real base.

* **Payment gateway** — implement `initiatePayment(Object)`. Delegated: `paymentModified`, `sync`,
  `cancel`, `reverse`, `expire`, `refund(DbRefund)`, `getDbPayment`, `supportsPreProcessing`,
  `supportsReverse`, `supportsHold`, `getPaymentProofs(DbPayment, fields)`.
* **Withdrawal gateway** — implement `processWithdrawal()`. Delegated: `withdrawalModified`,
  `withdrawalQuote(amount)`, `cancel`, `expireWithdrawal`, `sync`, `supportsPreProcessing`,
  `getWithdrawalLimits(tenantId, currency, gateway, type, subType)`.
* **Loan gateway** — implement `initiateLoan(Object)`, `loanOptIn(NewLoanOptIn)`,
  `updateLoan(Object)`, `loanLimit(customerId, productId)`, `getLoan(DbLoan)`,
  `getLoans(filter, offset, limit)`. Delegated: `loanOptOut`, `loanRePayment`,
  `supportsPreProcessing`, `supportsReverse`, `getLoanTransactions(tenantId, loanId, walletId)`.
* **Remittance gateway** — implement `initiateRemittance(Object)`, `getRemittance(DbRemittance)`.
  Delegated: `remittanceModified`, `cancel`, `reverse`, `userEnrollment`, `getUserEnrollmentStatus`,
  `updateUserEnrollment`, `supportsPreProcessing`, `supportsReverse`, `sync`, `getDbRemittance`,
  `getQuickQuotes(...)`, `getConfirmationQuotes(DbRemittance)`.

### Processor — key methods

* **Payment processor** (`IPaymentProcessor`): `enrichForCreation(NewEclipsePayment, NewPayment)`,
  `enrichWithUpdates(UpdatedPaymentTransactionData, Payment)`, `enrichResult(PaymentResult, Payment)`,
  `attemptPaymentCompletion(long tenantId, Payment)`,
  `enrichForRefund(Payment, EclipsePaymentRefund, NewPaymentRefund)`,
  `validatePaymentReceivingWallet(NewPayment)`.
* **Withdrawal processor** (`IWithdrawalProcessor`):
  `enrichForCreation(Wallet, NewEclipseWalletWithdrawal, Withdrawal, Long destWalletId)`,
  `enrichWithUpdates(Wallet, UpdatedWalletWithdrawal, Withdrawal)`,
  `enrichResult(Wallet, WalletWithdrawalResult, Withdrawal)`,
  `getWithdrawalFee(long tenantId, Wallet, String type, BigDecimal amount)`,
  `getWithdrawalLimit(long tenantId, Wallet, String type)`.

### Wiring a wallet type to its implementation class

Set `implementationClass` as a configuration AVP on the wallet type (mode `PLUGGABLE`):

```bash
curl -s -X PUT \
  "$BASE/eclipse-conductor/rest/v1/tenants/{tenantId}/wallet-types/{walletTypeId}" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name":"My Wallet Type", "mode":"PLUGGABLE",
        "configuration":[ {"att":"implementationClass","val":"com.eftcorp.plugins.DayLimitWallet"} ],
        "version": <current-version> }'
```

### Wiring a transfer pipeline to a wallet type

Add a configuration AVP to the **source** (debited) wallet type — or to an individual wallet — named
`transferPipeline.` followed by a regex matched against the transfer type. The value is the plugin's
`@ExtensionProvider` **name**, not its class name:

```bash
curl -s -X PUT \
  "$BASE/eclipse-conductor/rest/v1/tenants/{tenantId}/wallet-types/{walletTypeId}" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name":"My Wallet Type",
        "configuration":[ {"att":"transferPipeline.^payment\\.","val":"capped-transfer"},
                          {"att":"maxTransferAmount","val":"5000.00"} ],
        "version": <current-version> }'
```

How `TransferPipelineFactory` resolves it:

* The pipeline comes from the wallet **being debited**, so a plugin only ever runs on transfers out
  of that wallet.
* Matching is a partial match (`find`), so anchor the regex with `^` and `$` for an exact transfer
  type. A transfer with no type is matched as an empty string.
* A configured pipeline wins over the built-in pipeline selection (crypto, Momentum, SomBank, RCS,
  cross-tenant, cross-pool, VAT / markup splitting); a `forcedPipelineName` AVP on the transfer still
  wins over the configuration.
* If several `transferPipeline.*` attributes match, the first by attribute name order is used and a
  warning is logged — keep the regexes disjoint.
* A `transferPipeline.` attribute with no regex fails the transfer. So does a name with no registered
  provider, a provider not mapped to the tenant, and a provider whose plugin is off: a configured
  pipeline is never silently skipped, as it may be the control being enforced on that wallet.
* A regex that does not compile matches nothing (`UK.Regex` behaviour) and the built-in pipeline
  runs, so test the regex before relying on it.

***

## 9. Other implementation plugin types

### Fraud event handler

Extend the abstract `com.ukheshe.services.fraud.publish.pluggable.PluggableFraudEventPublishHandler`
(implements `FraudEventPublishHandler`) and implement the one method:

```java
@Singleton
public class MyFraudHandler extends PluggableFraudEventPublishHandler {
    @Override public FraudEventPublishResponse handle(FraudEventPublishEvent event) {
        var res = new FraudEventPublishResponse();
        res.setFraudResult(FraudEventPublishResult.NOT_FRAUD);
        return res;
    }
}
```

The `FraudEventPublishHandler` interface also provides `static`/`default` config helpers
(`getRequestConfig`, `getResponseConfig`, `getFieldMappingConfig(...)`) for tenant-specific field
mappings. Registration is by handler type:
`fraud.event.handlers.<handlerType> = com.example.MyFraudHandler`, where `<handlerType>` matches the
`handlerType` on the incoming `FraudEventPublishEvent`.

### Ratify (KYC) item processor

Extend `com.ukheshe.services.user.ratify.PluggableRatifyItemProcessor` (abstract; provides injected
`EntityManager`/`RequestContext` and `getRatifyInputData()`). Implement the processing hook (`go()`)
and emit a result with the supplied helpers.

### SMS provider / exchange-rate converter

These two are driven purely through the return-producing injection points `sendTextSms`,
`convertCurrency`, and `convertCurrencyHistoric` (§7) — implement those handlers and return the
value. The platform consumes the first non-null plugin response and falls back to the built-in
provider if none is returned.

***

## 10. Batch plugins

A pluggable bean that implements `com.ukheshe.arch.batch.ILocalBatchJob<T>` is **automatically
registered as a scheduled local batch job** when the plugin loads (and deregistered on unload) — no
extra wiring.

```java
@Dependent
public class NightlySweep implements ILocalBatchJob<String> {
    public int  getCommitFrequency()            { return 100; }     // items per committed tx
    public int  getMaxTps()                      { return 50;  }     // throttle per node
    public void init(Map<String,Object> state)  { /* setup; throw UnsupportedOperationException to skip */ }
    public Stream<String> supplyItems()          { return …; }       // produce work items
    public void processItem(String item)         { /* per-item work */ }
    public void onCommit(String lastCommitted)   { /* after each commit batch + final item */ }
    public Map<String,Object> onCompletion()     { return Map.of(); } // persisted as next run's state
    // from IBatchJob:
    public String getCron()                       { return "0 2 * * *"; } // null = kickoff-only, no schedule
    public ZoneId getCronTimeZone()               { return UK.DateTime.getUtcTimeZone(); }
    // optional gate (default true):
    // public boolean isJobProcessingWindowOpen() { return true; }
}
```

Lifecycle: `init` runs in its own transaction (throw `UnsupportedOperationException` to skip the
run); items from `supplyItems()` are processed by a producer/consumer pool throttled to
`getMaxTps()`, committing every `getCommitFrequency()` items and calling `onCommit`; `onCompletion()`'s
returned map is persisted as the next run's state. If `getCron()` is non-null the runner registers a
scheduled job (`groupName = "Local Batch"`, `uniqueId = <className>`); if null the job is
kickoff-only. Throughput defaults are tunable via `batch.max.tps.per.node` (100) and
`batch.threads.per.node` (1).

***

## 11. Listener plugins (wallet service only)

Internally, each listenable service is built as a four-layer listener chain:

```
<Service>Listener            (interface — the contract)
   ↑  SilentXxxListener       no-op base; super.x() does nothing
   ↑  PublishingXxxListener   super.x() publishes the domain event
   ↑  PluggableXxxListener    super.x() FIRES the named injection point, then chains up
   ↑  EclipseXxxListener      super.x() runs production logic (webhooks, fees, SMS, …)
```

For **payment, loan, remittance, user, attachment, property, and card (Postilion)** this chain is
purely internal plumbing. Their `PluggableXxxListener` layer exists only to fire injection points
and chain to `EclipseXxxListener`; it has **no plugin extension hook**. To customise those services,
write **injection-point handlers** (§6 / §7) — that is the only supported mechanism for them.

Only the **wallet service** exposes its `PluggableWalletListener` as a true plugin extension point.
It alone defines `setSuperClass(...)` / a `superclassDelegate`, so a plugin can subclass it and be
wired in as the active wallet listener:

```java
@ApplicationScoped @Unremovable @RegisterForReflection
public class MyWalletListener extends PluggableWalletListener {
    @PostConstruct void init() { setSuperClass(UK.Runtime.getBean(EclipseWalletListener.class)); }

    @Override public void postWalletCreate(Wallet wallet) {
        // before
        super.postWalletCreate(wallet);   // → delegate (EclipseWalletListener): injection point + publish + Eclipse logic
        // after
    }
}
```

When a delegate is set, each `PluggableWalletListener` method routes to it; calling
`super.method(...)` propagates through the injection-point firing, event publishing, and Eclipse
production layers, so all of them still happen. Skip the `super` call to suppress them.

> Even for wallets, prefer **injection-point handlers** (§6) unless you specifically need to wrap or
> replace the production listener — they're lighter, individually toggleable, and don't take over the
> active listener bean.

***

## 12. Plugin configuration JSON

On first upload the runtime scans the JAR and auto-generates a config JSON with **two sections**,
every entry defaulting to `true`:

```json
{
  "injectionPoints": { "com.eftcorp.plugins.MyHandlers#onTransfer": true },
  "beans":           { "com.eftcorp.plugins.DayLimitWallet": true }
}
```

* `injectionPoints` — keyed by `ClassName#methodName`; toggles individual `@OnNamedInjectionPoint`
  handlers on/off.
* `beans` — keyed by fully-qualified class name; toggles individual pluggable beans (wallet
  types, gateways, providers, batch jobs) on/off.

Updating the config **merges** with the stored version (your toggles win, auto-discovered keys are
preserved, removed keys are dropped) and applies the new enabled states to the live registries
immediately. Plugins can read arbitrary additional keys you place in this JSON via the injected
`PluginConfig` (`get(jsonPath)`, `getList(jsonPath, clazz)`, `getAll()`).

***

## 13. Tenant mappings

A plugin is either **global** (`global = true`, fires for every tenant) or restricted to an explicit
set of `tenantIds`. Injection-point handlers and provider beans both respect this — a non-global
plugin only fires for its mapped tenants, and `getPluginBean` throws for unmapped tenants. Note
property injection points pass `tenantId = null`, so a non-global plugin won't receive them.

***

## 14. Plugin management REST API

Implemented in `plugin-service` under `@Path("/rest/v1/plugins")`; reached through the conductor
gateway prefix `/eclipse-conductor`. All endpoints are authorization-gated.

| Action                     | Method | Path                                   | Body                                            |
| -------------------------- | ------ | -------------------------------------- | ----------------------------------------------- |
| List                       | GET    | `/rest/v1/plugins`                     | —                                               |
| Create / upsert            | POST   | `/rest/v1/plugins`                     | `Plugin`                                        |
| Update (incl. replace JAR) | PUT    | `/rest/v1/plugins/{pluginId}`          | `Plugin`                                        |
| Toggle status              | PUT    | `/rest/v1/plugins/{pluginId}/status`   | `Status` (`ON`/`OFF`/`DELETED`)                 |
| Update config              | PUT    | `/rest/v1/plugins/{pluginId}/configs`  | config JSON string                              |
| Update mappings            | PUT    | `/rest/v1/plugins/{pluginId}/mappings` | `Plugin` (uses `global` + `mappings.tenantIds`) |
| Delete                     | DELETE | `/rest/v1/plugins/{pluginId}`          | —                                               |

The `Plugin`**&#x20;DTO** fields: `pluginId` (long), `name`, `description`, `version`,
`status` (`ON`/`OFF`/`DELETED`), `global` (boolean), `created`, `lastModified`,
`base64EncodedJar` (String — the JAR bytes, Base64), `mappings` (`{ tenantIds: [long…] }`), and
`config` (String). To replace a plugin's code, `PUT` the same `pluginId` with a new
`base64EncodedJar`.

### Status semantics

* **ON** — loaded; handlers/beans active.
* **OFF** — unloaded from memory; nothing fires (still stored).
* **DELETED** — unloaded; purged from the database by a periodic sweep.

### Deploy example

```bash
TOKEN=$(jwt_login you@ukheshe.com .yoursecret | cut -d' ' -f2-3)
BASE=https://eclipse-java-sandbox.ukheshe.rocks
JAR_BASE64=$(base64 -w 0 target/uk-plugin-my-thing-1.0.0.jar)

# create
curl -s -X POST "$BASE/eclipse-conductor/rest/v1/plugins" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d "{\"status\":\"ON\",\"global\":true,\"base64EncodedJar\":\"$JAR_BASE64\",\"mappings\":{}}"

# update existing (replace JAR for pluginId 8)
curl -s -X PUT "$BASE/eclipse-conductor/rest/v1/plugins/8" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d "{\"pluginId\":8,\"status\":\"ON\",\"global\":true,\"base64EncodedJar\":\"$JAR_BASE64\",\"mappings\":{}}"
```

### Hot reload

The runtime polls the database every \~10 seconds and reconciles: new/changed JARs (compared by
SHA-256) are loaded, `OFF`/`DELETED` plugins are unloaded, mapping/config changes are applied to the
live registries. Uploading through the API also triggers an immediate sync.

***

## 15. Worked examples

Plugins under `plugins/` to copy from:

| Module                                      | Kind                                  | What it does                                                                                                  |
| ------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `plugin-day-limit`                          | Wallet type                           | `@Dependent extends PluggableWallet` enforcing a daily limit                                                  |
| `plugin-email-alert`                        | Wallet type + injection-point handler | `EmailAlertWalletType` + `TransferEmailAlert` on `wallet.postWalletTransfer`                                  |
| `plugin-attachment-info-length-validator`   | Validator (injection-point)           | `attachment.onAttachmentCreated/Updated` with `throwExceptions = true` to veto                                |
| `plugin-pcb-attachment-validator`           | Validator                             | same pattern, PCB-specific                                                                                    |
| `plugin-demo`                               | Mixed                                 | wallet type + **batch** (`PaulsDemoBatch`) + tenant-integration provider (`PcbTenantIntegration`)             |
| `plugin-ipos`                               | Provider                              | `@ExtensionProvider(type="TenantIntegrationProcessor", name="saveStoreData")`                                 |
| `plugin-user-service-fire-event-demo`       | Listeners                             | `user.onUserCreated`, `preAuth`, `onAuthSuccess/Failure`, `onPasswordUpdate`                                  |
| `plugin-user-service-oss-demo`              | OSS action processor                  | returns `Boolean.TRUE` to suppress default KYC processing                                                     |
| `plugin-user-service-ratify-processor-demo` | Ratify processor                      | `extends PluggableRatifyItemProcessor`                                                                        |
| `plugin-transfer-pipeline-demo`             | Transfer pipeline                     | `CappedTransferPipeline` extending `GenericTransferPipeline`, selected by `transferPipeline.<regex>` config   |
| `plugin-test`                               | Catch-all                             | fraud handler, `domainEvent`, wallet types, transfer pipeline (`TestTransferPipeline`), many injection points |

***

## 16. Quick reference

```java
// Minimal injection-point plugin
@PluginDescriptor(name = "my-plugin", version = "1.0", description = "Example")
public class MyPluginInfo { }

@Singleton
public class MyHandlers {
    @Inject EntityManager em;                       // one of the whitelisted @Inject types
    @OnNamedInjectionPoint(injectionPoint = "wallet.postWalletCreate", priority = 1)
    public void onWalletCreated(Wallet wallet) { … }
}
```

**Common pitfalls**

* ❌ Reflection, threads, `File`, sockets, `System`/`Runtime`, arbitrary HTTP libs — blocked by the
  sandbox (§3). Use injected REST clients and `UK.*` facades.
* ❌ Bundling `com.ukheshe.*` classes — provided by the platform; don't ship them.
* ❌ `@Inject` of an unsupported type — fails at load. Only the six types in §5 work.
* ❌ Forgetting `setSuperClass(...)` in `@PostConstruct` for a `Pluggable*` implementation — the
  delegate is null and `super.*` calls fail.
* ❌ Expecting an injection point to fire only for your wallet/tenant — they fire broadly; filter by
  `instanceof` / `getWalletTypeId()` / tenant.
* ❌ A transfer pipeline that is not `@Dependent` — the instance is reused and the second transfer
  fails with "Dependent bean transfer pipeline cannot be shared across contexts".
* ✔️ Use `throwExceptions = true` only when you intend to *veto* the operation; otherwise let errors
  be collected.

```
```