Skip to content

Latest commit

 

History

History
355 lines (266 loc) · 23.7 KB

File metadata and controls

355 lines (266 loc) · 23.7 KB

killbill-prism-plugin — Design

Design notes for a Kill Bill payment plugin that talks to any downstream connector (Stripe, Adyen, …) through the Hyperswitch Prism unified SDK.

Background reading:

  • ../reference/NOTES.md — recon on Adyen / Stripe plugins, the Kill Bill plugin framework, and the Prism Java SDK
  • Kill Bill — Payment Plugin docs
  • Prism repo docs/architecture/ (the public docs.hyperswitch.io/prism/prism URL currently 404s; canonical content is in the cloned repo)

1. Architecture

Prism is in-process: the Java SDK loads a Rust core via JNA/UniFFI and talks to the connector's HTTPS API directly from the plugin JVM. There is no Prism sidecar to run.

+----------------------------------------------------------------------+
|                          Kill Bill server                            |
|                                                                      |
|   +--------------------+        +-------------------------------+    |
|   |   Payment API      |------->|  PrismPaymentPluginApi (OSGi) |    |
|   |   (core)           |        |                               |    |
|   +--------------------+        |  - per-tenant config handler  |    |
|                                 |  - Prism PaymentClient cache  |    |
|   +--------------------+        |  - DAO (kb<->prism mapping)   |    |
|   | Webhook servlet    |<------ |                               |    |
|   | /webhook/{conn}    |        +-----+-------------------------+    |
|   +--------------------+              |                              |
|                                       v                              |
|                            +----------------------+                  |
|                            | io.hyperswitch:prism |   (Java SDK)     |
|                            +----------+-----------+                  |
+---------------------------------------+------------------------------+
                                        | JNA / UniFFI
                                        v
                            +----------------------+
                            |   Prism Rust core    |   (in-process)
                            |  connector adapters  |
                            +----------+-----------+
                                       | HTTPS
              +------------------------+------------------------+
              v                        v                        v
       +-------------+          +-------------+          +-------------+
       |   Stripe    |          |    Adyen    |          |     ...     |
       +-------------+          +-------------+          +-------------+
                                       ^
                                       | (webhook)
                                       |
                       Connector --POST-> /plugins/prism-plugin/webhook/{connector}

Implications:

  • No network hop to a Prism service — PaymentClient.authorize(...) is a direct in-JVM call that fans out to the connector via OkHttp inside the Rust core.
  • Statelessness is on Prism's side; we still need a DAO to map Kill Bill payment IDs ↔ connector transaction IDs (for follow-up captures/voids/refunds and webhook correlation). This matches the Adyen and Stripe plugins' pattern.
  • The single shared Rust core means the plugin JAR ships a native .so/.dylib/.dll extracted at startup. The Kill Bill bundle dir needs to be writable; OrbStack-managed Linux containers handle this transparently.

2. Payment method → connector mapping

A Kill Bill PaymentMethod is plugin-scoped but otherwise opaque to Kill Bill — the plugin decides what it represents. To route the same prism-plugin to different downstream connectors per payment method:

  • Persist a prismConnector field on each plugin payment-method row (prism_payment_methods.connector), set when addPaymentMethod is called.
  • The value is one of the connector slugs Prism recognises: stripe, adyen, checkout, worldpay, … (whatever is wired in the Rust core).
  • Plugin-property override: callers can pass prismConnector=<slug> on each payment call to force a specific connector, useful for one-off charges that don't have a stored method.
addPaymentMethod(kbPaymentMethodId, props={
    prismConnector = "stripe",
    prismConnectorToken = "pm_1234..."     // optional tokenized credential
})
   -> persists row in prism_payment_methods(kb_payment_method_id, connector="stripe", connector_token="pm_1234...")

purchasePayment(kbPaymentMethodId, ...)
   -> SELECT connector, connector_token FROM prism_payment_methods WHERE kb_payment_method_id = ?
   -> resolve PrismConfigProperties for (tenantId, "stripe")
   -> PaymentClient configured for Stripe → client.authorize(...)

Plugin properties (read on each call, override anything stored):

Property Purpose
prismConnector connector slug, e.g. stripe, adyen
prismConnectorToken pre-tokenized payment method (e.g. Stripe pm_…); avoids PCI on our side
prismCaptureMethod AUTOMATIC (purchase) or MANUAL (auth-then-capture) — defaulted by which KB method is called
prismAuthType NO_THREE_DS or THREE_DS (v2; v1 = NO_THREE_DS)

3. Per-tenant credential storage

Mirror the Adyen plugin's pattern (see reference/killbill-adyen-plugin/src/main/java/org/killbill/billing/plugin/adyen/core/AdyenConfigurationHandler.java):

  • Extend PluginTenantConfigurableConfigurationHandler<PrismConfigProperties>. Kill Bill fires a TENANT_CONFIG_CHANGE event whenever the tenant uploads new config; the handler caches the parsed PrismConfigProperties per tenant.

  • Upload is via Kill Bill's tenant-config endpoint:

    POST /1.0/kb/tenants/uploadPluginConfig/prism-plugin
    Content-Type: text/yaml
    
  • YAML lets us nest credentials per connector (the SDK's ConnectorConfig is shaped that way — connectorConfig.stripe.apiKey, connectorConfig.adyen.apiKey + merchantAccount, …):

    org.killbill.billing.plugin.prism:
      environment: SANDBOX            # or PRODUCTION; applied to all connectors unless overridden
      connectors:
        stripe:
          apiKey: sk_test_xxx
          webhookSecret: whsec_xxx
        adyen:
          apiKey: AQEqhmfxKx...
          merchantAccount: MyMerchant_TEST
          hmacKey: 11223344...        # for inbound webhook HMAC
  • PrismConfigProperties.forConnector("stripe") returns the per-connector slice used to build a Prism ConnectorConfig.

  • Nothing is committed to disk by the plugin. We hold credentials in-memory inside the handler (the framework's caching) and rebuild a Prism ConnectorConfig per request, then construct or look up a cached PaymentClient keyed by (tenantId, connector).

  • A bootstrap default for local dev only is read from org.killbill.billing.plugin.prism.* system properties on plugin start — same precedence rules Adyen uses.


4. Method → Prism call mapping

PrismPaymentPluginApi currently implements PaymentPluginApi (see PrismPaymentPluginApi.java and the TODO in README.md). Once the jOOQ DAO records exist we switch to extends PluginPaymentPluginApi<...>; the body of each method below stays the same.

Shorthand in the table:

  • req = PaymentService<Verb>Request.Builder populated from KB call args
  • client = paymentClientFor(tenantId, connector) — cached PaymentClient keyed by (tenantId, connector)
  • dao = PrismDao (to be built; mirrors AdyenDao)
Kill Bill method Prism call Request fields we set Response → PaymentTransactionInfoPlugin
authorizePayment client.authorize(req) merchantTransactionId=kbTransactionId, merchantOrderId=kbPaymentId, amount.minorAmount (KB amount × currency scale), amount.currency, captureMethod=MANUAL, connectorToken or raw paymentMethod.card, authType=NO_THREE_DS (v1) statusPaymentPluginStatus (§5), connectorTransactionIdfirstPaymentReferenceId, connectorResponseReferenceIdsecondPaymentReferenceId, raw response stored in prism_responses. Persist row before returning.
capturePayment client.capture(req) merchantCaptureId=kbTransactionId, connectorTransactionId (looked up via dao.getAuthorizeResponse(kbPaymentId)), amountToCapture Same mapping; new prism_responses row with transaction_type=CAPTURE.
purchasePayment client.authorize(req) with captureMethod=AUTOMATIC Same as authorize but captureMethod=AUTOMATIC. Same mapping; transaction_type=PURCHASE.
voidPayment client.void(req) connectorTransactionId from prior auth Same mapping; transaction_type=VOID.
refundPayment client.refund(req) merchantRefundId=kbTransactionId, connectorTransactionId of the captured/purchased txn, refundAmount (≤ original), reason (from PluginProperty if present) Map RefundStatus (§5), connectorRefundIdfirstPaymentReferenceId.
getPaymentInfo (no Prism call by default) Return rows from prism_responses for kbPaymentId. Force-refresh via client.get(req) (using connectorTransactionId) only if the inbound PluginProperty carries prismForceRefresh=true. This matches PluginPaymentPluginApi's default behaviour.
addPaymentMethod (none yet — v2) v1: persist kb_payment_method_id + prismConnector + optional prismConnectorToken. No call to Prism. v2 will call PaymentMethodClient.tokenize(...).
deletePaymentMethod (none) Delete the local row.
processNotification EventClient.handleEvent(req) (see §6) raw bytes, headers, connector slug EventClient returns a normalized event; we re-emit a Kill Bill GatewayNotification.
creditPayment, searchPayments, setDefaultPaymentMethod, searchPaymentMethods, resetPaymentMethods, buildFormDescriptor (unimplemented in v1) Return UnsupportedOperationException (current stub behaviour).

Currency conversion: Kill Bill passes BigDecimal amount + ISO currency. Prism's Money.minorAmount is in minor units. Use the currency's exponent (e.g. Currency.getDefaultFractionDigits()) — amount.movePointRight(exp).longValueExact(). Zero-decimal currencies (JPY, KRW) must not be silently rounded.


5. Status mapping

Kill Bill's status semantics (from the payment plugin docs):

PaymentPluginStatus Meaning Kill Bill transaction status
PROCESSED Gateway accepted, terminal success SUCCESS
ERROR Gateway-confirmed rejection PAYMENT_FAILURE
PENDING Async, expect a webhook to finalize PENDING
CANCELED We never reached the gateway (DNS/SSL/connect-refused) PLUGIN_FAILURE
UNDEFINED Outcome truly unknown (read timeout, 5xx) — possible silent success UNKNOWN

The KB docs are emphatic: CANCELED and UNDEFINED are for plugin-side failures. Don't reach for UNDEFINED whenever a status doesn't fit — it forces the operator into manual reconciliation.

Prism PaymentStatus → KB PaymentPluginStatus

From crates/types-traits/grpc-api-types/proto/payment.proto:151-201:

Prism status KB status Reason
CHARGED, PARTIAL_CHARGED, PARTIAL_CHARGED_AND_CHARGEABLE, AUTHORIZED, PARTIALLY_AUTHORIZED, VOIDED, VOIDED_POST_CAPTURE, AUTHENTICATION_SUCCESSFUL, AUTO_REFUNDED PROCESSED Terminal success states from the gateway's perspective. AUTHORIZED is PROCESSED for authorizePayment (capture is a separate KB transaction).
ROUTER_DECLINED, AUTHORIZATION_FAILED, AUTHENTICATION_FAILED, CAPTURE_FAILED, VOID_FAILED, FAILURE, EXPIRED ERROR Gateway explicitly declined / failed.
STARTED, PAYMENT_METHOD_AWAITED, DEVICE_DATA_COLLECTION_PENDING, CONFIRMATION_AWAITED, AUTHENTICATION_PENDING, AUTHORIZING, CAPTURE_INITIATED, VOID_INITIATED, COD_INITIATED, PENDING PENDING Async — expect a webhook callback.
UNRESOLVED, PAYMENT_STATUS_UNSPECIFIED UNDEFINED Prism itself doesn't know.
(no Prism status maps here) CANCELED Reserved for the exception path below.

Prism RefundStatus → KB PaymentPluginStatus

From payment.proto:221-229:

Prism refund status KB status
REFUND_SUCCESS PROCESSED
REFUND_FAILURE, REFUND_TRANSACTION_FAILURE ERROR
REFUND_PENDING, REFUND_MANUAL_REVIEW PENDING
REFUND_STATUS_UNSPECIFIED UNDEFINED

SDK exceptions → KB PaymentPluginStatus

Exception (from Prism SDK) KB status Notes
IntegrationError ERROR We built a malformed request — surfaces as a configuration bug, but the gateway has not been charged. Treat as ERROR so the operator sees a real failure; log loudly.
ConnectorError ERROR Connector returned an error we could parse.
NetworkError with errorCode = CONNECT_TIMEOUT | NETWORK_FAILURE CANCELED We never spoke to the gateway.
NetworkError with errorCode = RESPONSE_TIMEOUT | TOTAL_TIMEOUT UNDEFINED Request was sent; outcome unknown. Reconciler must follow up.
Anything else / uncaught UNDEFINED Default fallback only when we genuinely can't classify.

Always set gatewayError (free-text) and gatewayErrorCode (Prism error code string) on the returned PaymentTransactionInfoPlugin so it surfaces in Kaui.


6. Webhook handling

Connectors deliver webhooks directly to us (Stripe → its configured webhook URL, Adyen → its notification URL). Prism is not in the network path; we receive raw bodies. Prism's SDK does provide EventClient.handleEvent(...) to normalize the event payload once we've authenticated it locally.

Endpoint shape — one path per connector so we can wire each connector's dashboard to the right URL and keep signature schemes separate:

POST /plugins/prism-plugin/webhook/{connector}
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
       {connector} ∈ {stripe, adyen, ...}

Per-request flow:

  1. Verify signature in the servlet using the connector-specific secret from the per-tenant config (§3):
    • Stripe: HMAC-SHA256 over raw body using webhookSecret, compared against Stripe-Signature (with timestamp tolerance).
    • Adyen: com.adyen.util.HMACValidator.validateHMAC(...) over the parsed NotificationRequestItem using hmacKey.
    • Reject with 400 (or whatever the connector documents) on mismatch. Never trust the body before verification.
  2. Pass to EventClient.handleEvent(...) with the raw bytes + connector hint to get a normalized event (event type, normalized payment status, connectorTransactionId).
  3. Correlate to a Kill Bill transaction:
    • dao.getResponseByConnectorTransactionId(connector, connectorTransactionId)(kbPaymentId, kbTransactionId).
    • This works because we stored connectorTransactionId on the outbound request's response (see §4).
  4. Idempotency: INSERT IGNORE INTO prism_notifications(connector, event_id, ...). If the row already exists, return 200 immediately — the gateway is replaying.
  5. Advance Kill Bill state: osgiKillbillAPI.getPaymentApi().notifyPendingTransactionOfStateChanged(account, kbTransactionId, newStatus, callContext) if the prior KB transaction is PENDING.
  6. Persist the normalized event into prism_notifications and update the prism_responses row with the latest status.
  7. Reply with whatever the connector expects (Adyen: "[accepted]"; Stripe: 200).

What we are deliberately not doing:

  • We do not unify all connectors behind one /webhook endpoint. That works only if every connector's signature scheme matches the same byte representation we hand to a single verifier — and they don't.
  • We do not trust EventClient.handleEvent to verify signatures. Treat it as a parsing helper only; verification is our responsibility because the secret is per-tenant config.

7. Idempotency

Two layers — outbound (KB → Prism → connector) and inbound (connector → KB webhook):

Outbound

Map Kill Bill identifiers onto Prism's per-call idempotency keys:

Kill Bill Prism field Connector field (Stripe ex.)
kbPaymentId (UUID) merchantOrderId metadata.kb_payment_id
kbTransactionId (UUID, distinct per attempt) merchantTransactionId (auth/purchase), merchantCaptureId (capture), merchantRefundId (refund) Stripe Idempotency-Key header (Prism sets it from merchantTransactionId)
(Prism returns) connectorTransactionId Stripe pi_… / Adyen pspReference

kbTransactionId is the right idempotency key because Kill Bill generates a fresh one for every retry: the same business intent (re-trying a failed authorize) gets a new UUID, but Kill Bill's notifyPendingTransactionOfStateChanged and replay-on-restart paths preserve it within a single attempt. Using kbPaymentId would collapse retries into one connector call.

connectorTransactionId is persisted as prism_responses.connector_transaction_id so subsequent capture/void/refund calls don't depend on Kill Bill carrying it through plugin properties.

Inbound

  • prism_notifications has a unique index on (connector, event_id). INSERT IGNORE (MySQL/MariaDB) or ON CONFLICT DO NOTHING (PostgreSQL).
  • Stripe webhooks include id (e.g. evt_…); Adyen webhooks include eventCode + pspReference + originalReference — we'll derive a synthetic event_id for connectors that don't ship one.
  • The Adyen plugin currently stores every notification but doesn't dedupe — we won't repeat that.

8. v1 scope

In (this milestone):

  • Connectors: Stripe, Adyen.
  • Flows: authorizePayment, capturePayment, purchasePayment, voidPayment, refundPayment, getPaymentInfo.
  • processNotification for both connectors (Stripe HMAC + Adyen HMAC) with the per-connector servlet pattern in §6.
  • Per-tenant YAML config loader (§3).
  • prism_responses, prism_payment_methods, prism_notifications tables in ddl.sql; switch PrismPaymentPluginApi to extend PluginPaymentPluginApi<...> once the jOOQ records are generated.

Out (v2+):

  • Hosted Payment Page (buildFormDescriptor).
  • 3D-Secure / SCA flow (Prism authType=THREE_DS, redirect handling, verify_redirect_response).
  • Tokenization (PaymentMethodClient.tokenize, recurring detail references).
  • Additional connectors (Checkout, Worldpay, PayPal, …).
  • creditPayment, searchPayments, searchPaymentMethods, setDefaultPaymentMethod, resetPaymentMethods.
  • A reconciler / Janitor job for UNDEFINED-status transactions (Stripe plugin has one — useful but not blocking for v1).

9. SDK-binding

This section is internal scaffolding documentation, not a v1 design choice. It tracks why we don't statically link io.hyperswitch:prism yet and the conditions under which we will.

Current state: reflection through PrismClientFactory

io.hyperswitch:prism is not on Maven Central. PrismClientFactory therefore performs every SDK call through Class.forName(...) + Method.invoke(...), using these canonical FQNs:

Reflective lookup Role
com.juspay.hyperswitch.prism.PaymentClient Constructor takes a SdkConfig$ConnectorConfig
com.juspay.hyperswitch.prism.types.SdkConfig$ConnectorConfig Top-level proto message with newBuilder()
com.juspay.hyperswitch.prism.types.Payment$ConnectorSpecificConfig oneof proto with setStripe(...) / setAdyen(...)
com.juspay.hyperswitch.prism.types.Payment$StripeConfig setApiKey(SecretString)
com.juspay.hyperswitch.prism.types.Payment$AdyenConfig setApiKey, setMerchantAccount
com.juspay.hyperswitch.prism.types.Payment$SecretString setValue(String)

PrismActivator.start(...) probes for the first FQN with the plugin's own classloader. On miss, it logs ERROR and skips PaymentPluginApi registration — it does not throw, because a thrown start() aborts the entire Kill Bill plugin loader. Kill Bill simply won't route payments to the plugin until the SDK is wired and the bundle is reloaded.

Class.forName / Method.invoke are confined to PrismClientFactory.java. No other class in src/main/ references the SDK by name. Removing reflection is a one-file change.

FQN caveat to verify against a real JAR. The cloned Prism repo's Kotlin source declares package payments (e.g. sdk/java/src/main/kotlin/payments/Configs.kt:18), but the docs sample (docs/getting-started/installation.md) and the SDK README (sdk/java/README.md) use com.juspay.hyperswitch.prism.* and com.hyperswitch.payments.* respectively. We've standardized on com.juspay.hyperswitch.prism.* because that's what the docs publish for plugin authors. If the actually-published JAR exposes a different surface, the FQN constants in PrismClientFactory are the only thing to update.

Why reflection instead of just adding the dep

./gradlew publishToMavenLocal against reference/hyperswitch-prism/sdk/java fails with Unresolved reference 'types' in payments/Payments.kt because the Kotlin compile happens before the proto stubs are generated. The supported build path is the SDK's Makefile:

make build-lib            # crates/ffi with --features uniffi
make generate-bindings    # uniffi-bindgen --language kotlin
make generate-proto       # protoc --java_out  (requires protoc on PATH)
make pack-archive         # ./gradlew jar → artifacts/sdk-java/prism-*.jar

This needs cargo (have it), protoc (not installed), and the Kotlin/Gradle toolchain. We haven't taken the install hit yet; the reflective factory unblocks the rest of the plugin in the meantime.

Two paths to unblock

Option A — local install (works today).

brew install protobuf
cd reference/hyperswitch-prism/sdk/java
make pack
mvn install:install-file \
    -Dfile=artifacts/sdk-java/prism-*.jar \
    -DgroupId=io.hyperswitch \
    -DartifactId=prism \
    -Dversion=0.0.4 \
    -Dpackaging=jar

Then uncomment the <dependency>io.hyperswitch:prism</dependency> block in pom.xml and proceed to "Removing reflection" below.

Option B — wait for Maven Central. The SDK's build.gradle.kts is set up to publish via the central-publisher Gradle plugin, gated on CENTRAL_TOKEN_* env vars. The README claims version 0.0.6 was published; https://search.maven.org/solrsearch/select?q=g:io.hyperswitch+AND+a:prism returns zero. Track that endpoint; when it returns a hit, uncomment the dep with the latest version and proceed.

Removing reflection (definition of done)

The reflective code can be deleted once all of these are true:

  1. io.hyperswitch:prism resolves from the project's normal Maven repositories (local M2 or Central).
  2. pom.xml declares the dep at compile scope and mvn dependency:tree shows it transitively in scope.
  3. PrismClientFactory no longer references Class.forName or Method.invoke. All SDK calls are direct imports of com.juspay.hyperswitch.prism.*.
  4. PrismActivator.start(...) no longer probes; the missing-SDK branch is removed.
  5. src/test/java/com/juspay/hyperswitch/prism/ (the test stubs marked "TEST FIXTURE ONLY") is deleted. The unit test in src/test/java/org/killbill/billing/plugin/prism/sdkstub/PrismClientFactoryTest.java survives but now exercises the real classes — assertions on client.getConfig()...getApiKey().getValue() remain valid.
  6. CI proves it: grep -r "Class.forName\|Method.invoke" src/main/ returns no results.

Until step 6 is green, this section stays in DESIGN.md.