# SDK Integration Guide (Java)

> Build your integration on the JVM with the `payments-integration-sdk`. You implement one
> interface; a ready-made controller exposes the endpoints. End-to-end, with a worked example
> against a fictional PSP called **ExamplePay**. This guide is **self-contained** — it includes the full
> SDK reference (interface, controller, builders, exceptions, error mapping, fixtures) inline.
>
> Not on the JVM? Use the [API Integration Guide](05-api-integration-guide.md) instead.

Read first: [Overview & Concepts](01-overview-and-concepts.md) · [Payment Flows](03-payment-flows.md)

---

## Prerequisites

- JDK 25 and a Spring Boot 4 application (the SDK builds on Spring Web).
- Maven or Gradle.
- A PSP account and a client library/HTTP wrapper for it (here: `ExamplePayClient`).
- A publicly reachable HTTPS host (LoyaltyPlant must be able to call you).
- The SDK release version from LoyaltyPlant.

You can build most of this before onboarding; you only need LoyaltyPlant-provisioned values
(tokens, `integrationId`, the callback host) to go live — see
[General Requirements](02-general-requirements.md).

> **Note:** The `payments-integration-sdk` is **not publicly available**. LoyaltyPlant provides
> access after the project kickoff (see
> [Overview → Integration project stages](01-overview-and-concepts.md#3-integration-project-stages)).

---

## Step 1 — Add the dependency and create the app

```xml
<!-- The SDK: PaymentIntegration, the controller, builders, exceptions, ErrorCodeMapper.
     Transitively pulls in payments-integration-api (the generated request/response DTOs). -->
<dependency>
    <groupId>com.loyaltyplant.server.services</groupId>
    <artifactId>payments-integration-sdk</artifactId>
    <version>${payments-integration-sdk.version}</version>
</dependency>

<!-- Optional: ready-made test fixtures (IntegrationTestFixtures) -->
<dependency>
    <groupId>com.loyaltyplant.server.services</groupId>
    <artifactId>payments-integration-sdk</artifactId>
    <version>${payments-integration-sdk.version}</version>
    <type>test-jar</type>
    <scope>test</scope>
</dependency>
```

- **Use the release version** LoyaltyPlant gives you (the in-repo version is a `-SNAPSHOT`).
- `spring-boot-starter-web` is a **`provided`** dependency of the SDK — your application supplies
  it (you are running a Spring Boot web app).
- Generated DTOs live in `com.loyaltyplant.payments.integration.model.generated.v1`; the generated
  API interfaces (which the controller implements) live in
  `com.loyaltyplant.payments.integration.api.generated.v1`. **Both are generated from the OpenAPI
  spec — never edit them by hand** (see [Generated DTOs](#generated-dtos)).

```java
@SpringBootApplication
public class ExamplePayIntegrationApp {
    public static void main(String[] args) {
        SpringApplication.run(ExamplePayIntegrationApp.class, args);
    }
}
```

Ensure `PaymentIntegrationController` is component-scanned. It lives in
`com.loyaltyplant.payments.integration.sdk`, so either add that to your `@ComponentScan` base
packages or register it explicitly:

```java
@Configuration
@Import(com.loyaltyplant.payments.integration.sdk.PaymentIntegrationController.class)
class SdkConfig {}
```

That's all the wiring. `PaymentIntegrationController` is a `@RestController` that implements the
generated API interfaces and delegates to your `PaymentIntegration` bean — once that bean exists
(Step 2), **all 8 endpoints go live automatically**: `/v1/authorize`, `/v1/sale`, `/v1/capture`,
`/v1/refund`, `/v1/reverse`, `/v1/inquire-status`, `/v1/capabilities`, and `/v1/health`. The
controller binds the `Authorization` and `Idempotency-Key` headers as parameters, but it does
**not** validate the token or deduplicate requests — that's your responsibility (see Steps 4 and 8).

---

## Step 2 — Implement `PaymentIntegration`

### The general shape

`PaymentIntegration` (package `com.loyaltyplant.payments.integration.sdk`) is the one interface
you implement. Every payment operation has a `default` that throws `IntegrationUnsupportedException`
(→ HTTP `501`), so you override **only** the methods whose capability you declare in
`capabilities()` (Step 3) — LoyaltyPlant never calls an operation you didn't declare. The two
management methods are separate: `capabilities()` is required, and `health()` is optional (the
SDK's default reports `UP`; override it to reflect real PSP connectivity).

| Method | Endpoint | Required when you declare | Default if not overridden |
| --- | --- | --- | --- |
| `IntegrationCapabilitiesResponse capabilities()` | `GET /v1/capabilities` | **always (abstract)** | — must implement |
| `IntegrationHealthResponse health()` | `GET /v1/health` | optional | returns `UP` |
| `IntegrationOperationResponse authorize(IntegrationAuthorizeRequest)` | `POST /v1/authorize` | `CAPTURE` | throws `501` |
| `IntegrationOperationResponse capture(IntegrationCaptureRequest)` | `POST /v1/capture` | `CAPTURE` | throws `501` |
| `IntegrationOperationResponse sale(IntegrationSaleRequest)` | `POST /v1/sale` | `SALE` | throws `501` |
| `IntegrationOperationResponse refund(IntegrationRefundRequest)` | `POST /v1/refund` | `DIRECT_REFUND` | throws `501` |
| `IntegrationOperationResponse reverse(IntegrationReverseRequest)` | `POST /v1/reverse` | (no gating capability) | throws `501` |
| `IntegrationStatusInquiryResponse inquireStatus(IntegrationStatusInquiryRequest)` | `POST /v1/inquire-status` | `STATUS_POLLING` | throws `501` |
| `IntegrationEnrollResponse enroll(IntegrationEnrollRequest)` | `POST /v1/enroll` | `TOKENIZATION` | throws `501` |
| `IntegrationEnrollmentStatusResponse inquireEnrollmentStatus(IntegrationEnrollmentStatusRequest)` | `POST /v1/inquire-enrollment-status` | `TOKENIZATION` + `STATUS_POLLING` | throws `501` |

The request objects share a small, predictable set of accessors:

- **Every** request carries `getPaymentId()` (LoyaltyPlant's payment id — use it as your
  idempotency seed at the PSP) and `getSettings()` — a `Map<String,String>` provisioned by
  LoyaltyPlant per client/outlet (`currency` is always present, `merchantId` is common; you
  *read* it, you never define it).
- **Initiation** requests (`authorize` / `sale`) add `getAmount()` (major units, e.g. `49.99` —
  convert to minor units yourself if your PSP needs it) and `getCurrency()` (ISO 4217).
- **Follow-up** requests (`capture` / `refund` / `reverse`) carry `getTransactionReference()` —
  the reference you returned from the initiating operation.

> The asynchronous **result callback** is *not* a method on this interface — your PSP webhook
> receiver and the call back to LoyaltyPlant are code **you** write (Step 5); the SDK just gives
> you the `IntegrationOperationResponse` DTO and `IntegrationResponseBuilder` to build the body.

Pick the shape that matches how your PSP settles money, then jump to the matching example:

| | Two-phase — 2a | Single-phase — 2b |
| --- | --- | --- |
| **You declare** | `.capture()` | `.sale()` |
| **When money moves** | held by `authorize`, settled later by `capture` | authorized **and** captured in one call |
| **Use when** | you capture on fulfilment, do partial captures, or void (`reverse`) before capture | you don't hold funds separately |

Implement **one** shape per integration. `refund` is independent of the choice — declare
`DIRECT_REFUND` and implement it in either.

### 2a. Two-phase (`authorize` + `capture`)

The primary flow: hold funds with `authorize`, settle later with `capture`. This richer example
adds 3-D Secure (`redirectFlow`), refunds, voids (`reverse`), and status polling for
reconciliation.

```java
@Component
public class ExamplePayIntegration implements PaymentIntegration {

    private final ExamplePayClient psp;

    public ExamplePayIntegration(ExamplePayClient psp) {
        this.psp = psp;
    }

    @Override
    public IntegrationCapabilitiesResponse capabilities() {
        return IntegrationCapabilitiesBuilder.builder()
                .capture()        // two-phase
                .directRefund()
                .redirectFlow()   // 3DS
                .directWebhook()  // ExamplePay webhooks us; we call LP back
                .statusPolling()  // reconciliation
                .build();
    }

    @Override
    public IntegrationOperationResponse authorize(IntegrationAuthorizeRequest request) {
        // `settings` is provisioned by LoyaltyPlant per partner/outlet (currency, your merchantId, …).
        String merchantId = request.getSettings().get("merchantId");

        ExamplePayResult result = psp.authorize(
                request.getPaymentId(),               // use as your idempotency seed at the PSP
                request.getAmount(),                  // major units; convert to minor units if your PSP needs it
                request.getCurrency(),
                merchantId);

        if (result.requires3ds()) {
            // Suspend: hand LoyaltyPlant a redirect URL and your transaction reference.
            return IntegrationResponseBuilder.pending(
                    result.transactionId(),
                    result.redirectUrl(),
                    300);                              // expected TTL in seconds
        }
        return IntegrationResponseBuilder.success(result.transactionId());
    }

    @Override
    public IntegrationOperationResponse capture(IntegrationCaptureRequest request) {
        // amount is optional — null means "capture the full authorized amount".
        psp.capture(request.getTransactionReference(), request.getAmount());
        return IntegrationResponseBuilder.success(request.getTransactionReference());
    }

    @Override
    public IntegrationOperationResponse refund(IntegrationRefundRequest request) {
        psp.refund(request.getTransactionReference(), request.getAmount());
        return IntegrationResponseBuilder.success(request.getTransactionReference());
    }

    @Override
    public IntegrationOperationResponse reverse(IntegrationReverseRequest request) {
        psp.voidAuthorization(request.getTransactionReference());
        return IntegrationResponseBuilder.success(request.getTransactionReference());
    }

    @Override
    public IntegrationStatusInquiryResponse inquireStatus(IntegrationStatusInquiryRequest request) {
        ExamplePayStatus s = psp.lookup(request.getTransactionReference());
        var response = new IntegrationStatusInquiryResponse(mapStatus(s));   // AUTHORIZED/CAPTURED/…
        response.setTransactionReference(request.getTransactionReference());
        response.setRawStatus(s.raw());
        return response;
    }
}
```

### 2b. Single-phase (`sale`)

The simpler variant: a synchronous charge plus refunds. These capabilities mirror the
`saleOnlyCapabilities()` test fixture (Step 7).

```java
@Component
public class ExamplePayIntegration implements PaymentIntegration {

    private final ExamplePayClient psp;

    public ExamplePayIntegration(ExamplePayClient psp) {
        this.psp = psp;
    }

    @Override
    public IntegrationCapabilitiesResponse capabilities() {
        return IntegrationCapabilitiesBuilder.builder()
                .sale()           // single-phase: authorize + capture in one call
                .directRefund()
                .build();
    }

    @Override
    public IntegrationOperationResponse sale(IntegrationSaleRequest request) {
        String merchantId = request.getSettings().get("merchantId");
        ExamplePayResult result = psp.charge(
                request.getPaymentId(),     // idempotency seed at the PSP
                request.getAmount(),        // major units; convert to minor units if your PSP needs it
                request.getCurrency(),
                merchantId);
        return IntegrationResponseBuilder.success(result.transactionId());
    }

    @Override
    public IntegrationOperationResponse refund(IntegrationRefundRequest request) {
        psp.refund(request.getTransactionReference(), request.getAmount());
        return IntegrationResponseBuilder.success(request.getTransactionReference());
    }
}
```

That's a complete single-phase integration. You don't implement `authorize` / `capture` /
`reverse` — you didn't declare `CAPTURE`, so their defaults return `501`, which is correct. Map
PSP failures (declines, timeouts) as shown in Step 6. If your single-phase flow can *also*
require 3-D Secure, return `pending(...)` exactly as the two-phase `authorize()` does above.

Notes:

- Implement **one** shape: in two-phase you don't implement `sale()`; in single-phase you don't
  implement `authorize` / `capture` / `reverse`. Undeclared operations fall through to the `501`
  default, which is correct.
- `request.getSettings()` is a `Map<String,String>` provisioned by LoyaltyPlant. Read keys you
  agreed during onboarding (`currency` is always present; `merchantId` is common).

### Generated DTOs

The request/response models you pass and return are **generated from the OpenAPI spec** into
`com.loyaltyplant.payments.integration.model.generated.v1`:

`IntegrationAuthorizeRequest`, `IntegrationSaleRequest`, `IntegrationCaptureRequest`,
`IntegrationRefundRequest`, `IntegrationReverseRequest`, `IntegrationOperationResponse`,
`IntegrationStatus`, `IntegrationCapability`, `IntegrationCapabilitiesResponse`,
`IntegrationHealthResponse`, `IntegrationStatusInquiryRequest`,
`IntegrationStatusInquiryResponse`, `PaymentErrorCode`.

- Field-by-field documentation:
  [the spec](/payments/api/) is the
  single source of truth (it generates these classes **and** their Javadoc).
- **Do not edit generated sources.** To change a model, change the spec and regenerate.
- Naming note: the wire value `FAILURE_3DS_CHECK` becomes the Java constant
  `PaymentErrorCode.FAILURE_3_DS_CHECK` (its `.getValue()` is still `"FAILURE_3DS_CHECK"`).

---

## Step 3 — Declare capabilities honestly

`capabilities()` (Step 2) is the contract LoyaltyPlant relies on — it only calls operations
whose capability you declared. **Declare only what you've implemented and tested.** Two-phase
integrations declare `.capture()`; single-phase integrations declare `.sale()` — see examples
2a and 2b above.

Build the response with the fluent `IntegrationCapabilitiesBuilder`
(package `com.loyaltyplant.payments.integration.sdk`):

| Method | Capability | Method | Capability |
| --- | --- | --- | --- |
| `.capture()` | `CAPTURE` | `.directWebhook()` | `DIRECT_WEBHOOK` |
| `.sale()` | `SALE` | `.tokenization()` | `TOKENIZATION` |
| `.directRefund()` | `DIRECT_REFUND` | `.statusPolling()` | `STATUS_POLLING` |
| `.redirectFlow()` | `REDIRECT_FLOW` | `.applePay()` | `APPLE_PAY` |
| `.googlePay()` | `GOOGLE_PAY` | `.supports(IntegrationCapability)` | any (generic) |

Declaring the same capability twice is a no-op (de-duplicated).

> `applePay()` and `googlePay()` have **no dedicated method** on `PaymentIntegration` — the
> wallet payload arrives as `request.getPaymentInstrument().getWalletPayload()` inside
> `authorize`/`sale`. `tokenization()` **does** have its own methods (`enroll`,
> `inquireEnrollmentStatus`) — see [Step 5b](#5c-card-enrollment).

---

## Step 4 — Build responses (the response contract)

Use `IntegrationResponseBuilder` and let the controller turn your return values / exceptions into
the right HTTP response. **Never** return an HTTP error for a business decline — throw
`IntegrationDeclinedException` (→ `200 DECLINED`) instead.

| Outcome | Do this |
| --- | --- |
| Approved | `return IntegrationResponseBuilder.success(ref);` |
| Needs 3DS/redirect | `return IntegrationResponseBuilder.pending(ref, url, ttl);` |
| Declined | `throw new IntegrationDeclinedException(code, reason);` |
| Transient/retry | `throw new IntegrationRetriableException(reason, cause);` |
| Not implemented | leave the method un-overridden (default → `501`) |

### Return value / exception → HTTP mapping

This is what makes the SDK enforce the response contract. For the payment operations
(`authorize`/`sale`/`capture`/`refund`/`reverse`):

| Your method… | Controller emits |
| --- | --- |
| returns an `IntegrationOperationResponse` | `200` + that body (e.g. `SUCCESS` / `PENDING`) |
| throws `IntegrationDeclinedException(errorCode, reason)` | `200` + `{ "status": "DECLINED", "errorCode", "reason" }` |
| throws `IntegrationRetriableException(reason)` | `200` + `{ "status": "RETRIABLE", "reason" }` |
| throws `IntegrationUnsupportedException` | `501` (non-retriable) |
| throws any other exception | `200` + `{ "status": "UNKNOWN", "reason": <message> }` |

The two management/read endpoints differ slightly:

| Endpoint | Behavior |
| --- | --- |
| `inquireStatus` | returns `200`; `IntegrationUnsupportedException` → `501`; any other exception → `500` |
| `getHealth` | returns `200`; any exception → `503` |
| `getCapabilities` | returns `200` with your declared capabilities |

> To return `PENDING`, you must **return** `IntegrationResponseBuilder.pending(...)` — there is
> no "pending exception". Exceptions only express declines, retriable, unsupported, or unknown.

> **Idempotency is yours to implement.** The SDK only *shapes* responses — it does **not**
> deduplicate requests, and the `Idempotency-Key` header is **not passed to your
> `PaymentIntegration` method**. Make `authorize`/`sale`/`capture`/`refund`/`reverse` idempotent so
> a retry never double-charges — either dedupe on `paymentId` (or your PSP's own idempotency), or
> read the `Idempotency-Key` header yourself in a Spring `Filter`/`HandlerInterceptor` (the same
> place you validate the outbound token) and store `(key → response)`. See
> [the spec](/payments/api/) for the contract.

### `IntegrationResponseBuilder`

Package: `com.loyaltyplant.payments.integration.sdk`. Static factories for the operation response.
Prefer these over constructing DTOs by hand.

| Factory | Produces |
| --- | --- |
| `success(String transactionReference)` | `{ status: SUCCESS, transactionReference }` |
| `pending(String transactionReference)` | `{ status: PENDING, transactionReference }` |
| `pending(String transactionReference, String redirectUrl, int expectedTtlSeconds)` | `{ status: PENDING, transactionReference, redirectUrl, expectedTtlSeconds }` |
| `declined(String errorCode, String reason)` | `{ status: DECLINED, errorCode, reason }` |
| `retriable(String reason)` | `{ status: RETRIABLE, reason }` |
| `unknown(String reason)` | `{ status: UNKNOWN, reason }` |

> `declined(errorCode, …)` accepts the **wire string** of a `PaymentErrorCode` (e.g.
> `"NOT_ENOUGH_MONEY"`). If the string isn't a known code it falls back to `GATEWAY_ERROR`,
> so pass a valid value — or use [`ErrorCodeMapper`](#errorcodemapper) to map a PSP code.

---

## Step 5 — Asynchronous flow (3-D Secure) and the result callback

When `authorize()` returns `PENDING`, the payment is suspended at LoyaltyPlant until you
report the outcome. Two pieces of code you write **yourself** (the SDK doesn't generate them):

### 5a. Receive your PSP's webhook

ExamplePay calls a webhook on **your** service when the customer finishes 3-D Secure. This is
your own endpoint — define it however you like.

```java
@RestController
public class ExamplePayWebhookController {

    private final ExamplePayClient psp;
    private final LoyaltyPlantResultClient lpClient;   // Step 5b

    @PostMapping("/psp/examplepay/webhook")
    public ResponseEntity<Void> onWebhook(@RequestBody String rawBody,
                                          @RequestHeader("ExamplePay-Signature") String signature) {
        // 1. ALWAYS verify the signature before trusting the payload.
        if (!psp.verifyWebhookSignature(rawBody, signature)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        ExamplePayWebhook event = psp.parseWebhook(rawBody);

        // 2. Report the final result to LoyaltyPlant.
        IntegrationOperationResponse result = event.approved()
                ? IntegrationResponseBuilder.success(event.transactionId())
                : IntegrationResponseBuilder.declined(
                        ErrorCodeMapper.mapToString(event.declineCode()), event.message());

        lpClient.reportResult(event.transactionId(), result);
        return ResponseEntity.ok().build();           // acknowledge to ExamplePay
    }
}
```

### 5b. Call LoyaltyPlant back

POST the result to LoyaltyPlant's result endpoint. Authenticate with your **inbound** token
(not the outbound token you validate on incoming calls) and send an `Idempotency-Key`.

```java
@Component
public class LoyaltyPlantResultClient {

    private final HttpClient http = HttpClient.newHttpClient();
    private final ObjectMapper mapper;          // your JSON mapper

    // All three values are provisioned by LoyaltyPlant during onboarding (store them securely).
    @Value("${lp.base-url}")        String lpBaseUrl;       // e.g. https://payments.loyaltyplant.example
    @Value("${lp.integration-id}") String integrationId;
    @Value("${lp.inbound-token}")  String inboundToken;     // secret

    public void reportResult(String transactionReference, IntegrationOperationResponse result) {
        try {
            String body = mapper.writeValueAsString(result);   // transactionReference MUST be set & match the PENDING ref
            HttpRequest req = HttpRequest.newBuilder()
                    .uri(URI.create(lpBaseUrl + "/gate/integration/" + integrationId + "/result"))
                    .header("Authorization", "Bearer " + inboundToken)
                    .header("Idempotency-Key", UUID.randomUUID().toString())
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
            if (resp.statusCode() != 200) {
                // Retry with the SAME Idempotency-Key on 5xx/timeout. 200 means resumed (or already resumed).
                throw new IllegalStateException("LP result callback failed: HTTP " + resp.statusCode());
            }
        } catch (Exception e) {
            // schedule a retry; replays are safe (idempotent by transactionReference)
            throw new RuntimeException(e);
        }
    }
}
```

Critical details (see [Payment Flows §4](03-payment-flows.md#4-asynchronous-flows-3-d-secure--redirect--the-keystone)):

- The `transactionReference` in the callback **must equal** the one you returned in the
  `PENDING` response — that's how LoyaltyPlant finds the suspended payment.
- Use the **inbound** token. Using the outbound token → `401`.
- Retry until `200`; replays are no-ops.
- If you can't webhook, declare `STATUS_POLLING` so LoyaltyPlant can reconcile via
  `inquireStatus`.

### 5c. Card enrollment

If you declare `tokenization()`, implement `enroll` — the same async shape, one extra callback.

```java
@Override
public IntegrationEnrollResponse enroll(final IntegrationEnrollRequest request) {
    // request.getEnrollmentId()  — echo this back in the callback
    // request.getCustomer()      — email / name / language for your hosted page
    // request.getReturnUrls()    — where to send the browser afterwards (navigation only)
    final var session = psp.createCardEntrySession(
            request.getSettings().get("merchantId"),
            request.getReturnUrls().getSuccessUrl().toString());

    return IntegrationResponseBuilder.enrollPending(session.id(), session.url(), 900);
}
```

If your PSP tokenizes without a redirect, answer synchronously instead:

```java
return IntegrationResponseBuilder.enrollSuccess(session.id(),
        IntegrationResponseBuilder.card("tok_…", "**** 4242", "VISA", 12, 2029));
```

When your PSP reports the card is bound, POST the token to LoyaltyPlant — exactly like the
result callback in 5b, but to `/gate/integration/{id}/enrollment-result` and carrying both
`enrollmentId` and `transactionReference`:

```java
final var body = objectMapper.writeValueAsString(Map.of(
        "enrollmentId", enrollmentId,
        "transactionReference", pspReference,
        "status", "SUCCESS",
        "card", Map.of("token", pspToken, "maskedPan", "**** 4242", "brand", "VISA",
                       "expiryMonth", 12, "expiryYear", 2029)));

httpClient.send(HttpRequest.newBuilder()
        .uri(URI.create(lpBaseUrl + "/gate/integration/" + integrationId + "/enrollment-result"))
        .header("Authorization", "Bearer " + inboundToken)
        .header("Idempotency-Key", UUID.randomUUID().toString())
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build(), HttpResponse.BodyHandlers.ofString());
```

Later payments then arrive with the instrument set:

```java
final var instrument = request.getPaymentInstrument();
if (instrument != null) {
    switch (instrument.getType()) {
        case CARD_TOKEN -> psp.chargeSavedCard(instrument.getToken(), amount);
        case APPLE_PAY, GOOGLE_PAY -> psp.chargeWallet(instrument.getWalletPayload(), amount);
    }
}
```

Critical details (see [Card enrollment](03-payment-flows.md#5-card-enrollment-and-tokenization)):

- The token goes **only** through the callback. A token appended to `returnUrls` is discarded.
- Only `SUCCESS` (with `card`) and `DECLINED` are accepted; anything else is a `400`.
- `maskedPan` must be a mask. A full card number is rejected.
- Implement `inquireEnrollmentStatus` (resolving by `enrollmentId`) so a lost callback doesn't
  strand a card your PSP already bound.
- Never log `token` or `walletPayload`.

---

## Step 6 — Error handling

Map PSP failures to the contract. Use the SDK's [`ErrorCodeMapper`](#errorcodemapper) to translate
PSP decline codes to a standard `PaymentErrorCode`.

```java
@Override
public IntegrationOperationResponse authorize(IntegrationAuthorizeRequest request) {
    try {
        ExamplePayResult r = psp.authorize(/* … */);
        return r.requires3ds()
                ? IntegrationResponseBuilder.pending(r.transactionId(), r.redirectUrl(), 300)
                : IntegrationResponseBuilder.success(r.transactionId());
    } catch (ExamplePayDeclined e) {
        // business decline → 200 DECLINED
        throw new IntegrationDeclinedException(ErrorCodeMapper.mapToString(e.code()), e.getMessage());
    } catch (ExamplePayTimeout | IOException e) {
        // transient → 200 RETRIABLE (LP may retry; keep it idempotent)
        throw new IntegrationRetriableException("ExamplePay timeout", e);
    }
    // any other exception → the controller emits 200 UNKNOWN
}
```

### Exception hierarchy

Package: `com.loyaltyplant.payments.integration.sdk.exception`. All extend `IntegrationException`
(a `RuntimeException`). Throw these from your `PaymentIntegration` methods; the controller maps
them per [Step 4](#return-value--exception--http-mapping).

| Exception | Constructors | Controller result |
| --- | --- | --- |
| `IntegrationException` | `(String)`, `(String, Throwable)` | base type (don't throw directly) |
| `IntegrationDeclinedException` | `(String errorCode, String reason)` — exposes `getErrorCode()` | `200 DECLINED` |
| `IntegrationRetriableException` | `(String)`, `(String, Throwable)` | `200 RETRIABLE` |
| `IntegrationUnsupportedException` | `(String operation)` → message `"Operation not supported: <op>"` | `501` |
| `IntegrationConfigurationException` | `(String)`, `(String, Throwable)` | (startup/config use; surfaces as `UNKNOWN` if thrown during an operation) |

### `ErrorCodeMapper`

Package: `com.loyaltyplant.payments.integration.sdk`. Maps common ISO 8583 numeric codes and
widespread PSP string codes to a standard `PaymentErrorCode`. Falls back to `GATEWAY_ERROR` for
anything unrecognized.

| Method | Returns |
| --- | --- |
| `PaymentErrorCode fromPspCode(@Nullable String pspCode)` | the mapped enum (or `GATEWAY_ERROR`) |
| `String mapToString(@Nullable String pspCode)` | the mapped enum's wire value (for `IntegrationResponseBuilder.declined`) |

A few of the built-in mappings (see the source for the full table):

| PSP code | → `PaymentErrorCode` |
| --- | --- |
| `"51"`, `"insufficient_funds"` | `NOT_ENOUGH_MONEY` |
| `"54"`, `"expired_card"`, `"card_expired"` | `CARD_EXPIRED` |
| `"82"`, `"3ds_failed"`, `"authentication_required"` | `FAILURE_3DS_CHECK` |
| `"incorrect_cvc"`, `"invalid_cvc"` | `FAILURE_SECRET_CODE_CHECK` |
| `"fraudulent"`, `"suspected_fraud"` | `ANTIFRAUD` |
| *(unrecognized)* | `GATEWAY_ERROR` |

---

## Step 7 — Test with the fixtures

The test-jar ships ready-made requests/responses (`IntegrationTestFixtures`, package
`com.loyaltyplant.payments.integration.sdk`):

| Fixture | Returns |
| --- | --- |
| `authorizeRequest()` | an `IntegrationAuthorizeRequest` (99.99 USD) |
| `authorizeRequest(BigDecimal amount, String currency)` | a customised authorize request |
| `saleRequest()` | an `IntegrationSaleRequest` (49.99 USD) |
| `captureRequest(String transactionReference)` | a capture request (99.99) |
| `refundRequest(String transactionReference)` | a refund request (99.99) |
| `reverseRequest(String transactionReference)` | a reverse request |
| `successResponse()` | `IntegrationResponseBuilder.success(...)` |
| `pendingResponse()` | a `PENDING` response with `redirectUrl` + 300s TTL |
| `declinedResponse()` | a `DECLINED` response (`NOT_ENOUGH_MONEY`) |
| `twoPhaseCapabilities()` | `CAPTURE, DIRECT_REFUND, REDIRECT_FLOW, DIRECT_WEBHOOK` |
| `saleOnlyCapabilities()` | `SALE, DIRECT_REFUND` |
| `healthUp()` / `healthDown()` | `UP` / `DOWN` health responses |

```java
import static com.loyaltyplant.payments.integration.sdk.IntegrationTestFixtures.*;
import static org.junit.jupiter.api.Assertions.*;

class ExamplePayIntegrationTest {

    ExamplePayClient psp = mock(ExamplePayClient.class);
    ExamplePayIntegration integration = new ExamplePayIntegration(psp);

    @Test
    void declares_capabilities() {
        var caps = integration.capabilities().getCapabilities();
        assertTrue(caps.contains(IntegrationCapability.CAPTURE));
    }

    @Test
    void authorize_success() {
        when(psp.authorize(any(), any(), any(), any()))
                .thenReturn(ExamplePayResult.approved("txn_123"));
        var r = integration.authorize(authorizeRequest());
        assertEquals(IntegrationStatus.SUCCESS, r.getStatus());
        assertEquals("txn_123", r.getTransactionReference());
    }

    @Test
    void authorize_requires_3ds_returns_pending() {
        when(psp.authorize(any(), any(), any(), any()))
                .thenReturn(ExamplePayResult.threeDs("txn_123", "https://acs.bank/3ds"));
        var r = integration.authorize(authorizeRequest());
        assertEquals(IntegrationStatus.PENDING, r.getStatus());
        assertNotNull(r.getRedirectUrl());
    }
}
```

Also test against the response contract (Step 4): assert declines surface as `DECLINED` (not
exceptions leaking as `500`), and that an idempotent replay returns the same result.

---

## Step 8 — Get provisioned by LoyaltyPlant

Your service is built; now LoyaltyPlant provisions you and runs certification. What you exchange
(tokens, `integrationId`, `settings`, limits, callback host), how the two tokens flow,
environments, activation states, and security are all in
[General Requirements](02-general-requirements.md).

> SDK note: enforce outbound-token validation (see
> [General Requirements §4](02-general-requirements.md#4-token-directionality-read-this-twice))
> in a Spring `Filter`/`HandlerInterceptor` in front of `/v1/**`, or via Spring Security.

---

## Step 9 — Pre-production checklist

Before go-live, walk the [Integration Checklist](07-integration-checklist.md). The big ones:
capabilities declared honestly, idempotency verified, the **async result callback tested
against a real PENDING transaction**, outbound-token validation enforced, and `health()`
reflecting your real PSP connectivity.

---

## Versioning

The version segment in the endpoint paths (`/v1/...`) corresponds to the OpenAPI spec `version`
and the package suffix (`...generated.v1`). LoyaltyPlant records the `apiVersion` it should call
for your integration during onboarding; keep your deployed SDK
version and that `apiVersion` in step.

---

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| LoyaltyPlant gets `501` for an operation you implemented | The method isn't overriding the interface method (signature mismatch), or the capability isn't declared. |
| Every call returns `401` | Outbound-token validation rejecting valid tokens, or token not yet provisioned. |
| 3DS payments never complete | `PENDING` returned without a `transactionReference`, or the result callback isn't being sent / uses the wrong token / wrong `transactionReference`. |
| Declines look like errors to LoyaltyPlant | You're throwing a generic exception (→ `UNKNOWN`) instead of `IntegrationDeclinedException` (→ `DECLINED`). |
| Endpoints 404 | `PaymentIntegrationController` isn't component-scanned (see Step 1). |
| Result callback returns `404` | Wrong `integrationId`, or integration not active yet. |
| Result callback returns `401` | Using the outbound token instead of the inbound token. |

---

**Next:** [Message & Error Codes →](06-message-and-error-codes.md)
