# Axim Pay 연동 구현 지침서

> **작성일**: 2026-03-29
> **대상 모듈**: `partner-api`, `open-api`, `core`
> **참조**: v1 `coin-payments` — `AximPayClient`, `AximService`, `AximSettingsService`, `AximWebhookController`

---

## 1. 현황 및 문제점

### v2 현재 상태 (❌ 미완성)

| 항목 | 상태 | 설명 |
|------|------|------|
| 설정 저장 (apiKey/secret) | ✅ | DB INSERT/UPDATE만 수행 |
| Axim API Key 검증 | ❌ | 저장 시 유효성 검증 없음 |
| Webhook URL 등록 | ❌ | Axim 서버에 콜백 URL 미등록 |
| 마스터 지갑 등록 | ❌ | Axim 서버에 입금 지갑 미등록 |
| siteId 조회 | ❌ | DB에 NULL — Axim API 미호출 |
| init-info 실시간 조회 | ❌ | DB만 읽음, Axim API 미호출 |
| Webhook 수신/처리 | ❌ | 엔드포인트 없음 |
| connectId 생성 | ❌ | Widget에 connectId 미제공 |

### v1 정상 플로우 (✅ 참조)

```
[파트너 콘솔] API Key/Secret 입력 → DB 저장
[파트너 콘솔] "활성화" 버튼 클릭 →
  1. GET /api/v1/open/partners/me → API Key 유효성 검증 + siteId 획득
  2. PUT /api/v1/open/partners/webhook-url → Webhook URL 등록
  3. POST /api/v1/open/partners/deposit-wallets → 마스터 지갑 등록 (체인별)
  4. DB status → ACTIVE

[위젯 로드] GET /widgets/api/axim/init-info →
  1. GET /api/v1/open/partners/me → siteId 실시간 조회
  2. connectId 생성 (partnerId + partnerUserId 인코딩)
  3. 응답: { enabled, siteId, connectId, apiKey, partnerName }

[Axim 서버] POST /webhooks/axim/{partnerId} → 이벤트 처리
  - connection.succeeded → 외부 지갑 연결 (connectWalletToken 저장)
  - payment.succeeded → 결제 확정
  - 등 7가지 이벤트
```

---

## 2. Axim Pay API 스펙

### Base URL

```
https://pay.axim.one
```

### 인증 헤더 (HMAC-SHA256)

```java
// 서명 생성: HMAC-SHA256(timestamp + "." + apiKey, secretKey) → Base64
String data = timestamp + "." + apiKey;
String accessToken = Base64.encode(HMAC_SHA256(data, secretKey));

// 헤더 설정
X-API-KEY: {apiKey}
X-TIMESTAMP: {epochSeconds}
X-ACCESS-TOKEN: {accessToken}  // Base64 인코딩
Content-Type: application/json
```

**주의**: Cryptoments 내부 HMAC은 hex 인코딩이지만, **Axim Pay API는 Base64 인코딩**을 사용한다.

### API 엔드포인트

| Method | Path | 용도 |
|--------|------|------|
| `GET` | `/api/v1/open/partners/me` | 파트너 정보 조회 (siteId 획득) |
| `PUT` | `/api/v1/open/partners/webhook-url` | Webhook URL 등록/수정 |
| `GET` | `/api/v1/open/partners/deposit-wallets` | 입금 지갑 목록 조회 |
| `POST` | `/api/v1/open/partners/deposit-wallets` | 입금 지갑 등록 |
| `POST` | `/api/v1/open/payments` | 결제 생성 |
| `GET` | `/api/v1/open/payments` | 결제 목록 조회 |
| `GET` | `/api/v1/open/payments/{transactionId}` | 결제 상세 조회 |
| `GET` | `/api/v1/open/payments/{transactionId}/status` | 결제 상태 조회 |
| `POST` | `/api/v1/open/payments/{transactionId}/cancel` | 결제 취소 |
| `GET` | `/api/v1/open/connections` | 연결 목록 조회 |
| `GET` | `/api/v1/open/connections/{connectId}` | 연결 정보 조회 |
| `GET` | `/api/v1/open/networks/best` | 최적 네트워크 조회 |

---

## 3. 구현 항목 (7개)

### 3-1. AximPayClient (common 모듈) — 신규 생성

**파일**: `common/src/main/java/com/cryptoments/common/client/AximPayClient.java`

Axim Pay 외부 API를 호출하는 HTTP 클라이언트.

```java
package com.cryptoments.common.client;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
// ... imports

/**
 * Axim Pay Open API 클라이언트.
 * HMAC-SHA256(timestamp.apiKey, secret) → Base64 인증.
 */
@Component
public class AximPayClient {

    private static final String BASE_URL = "https://pay.axim.one";
    private static final String HMAC_ALGORITHM = "HmacSHA256";

    private final RestClient restClient;

    public AximPayClient(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.baseUrl(BASE_URL).build();
    }

    // ── 인증 헤더 생성 ──

    /**
     * HMAC-SHA256 인증 헤더 생성.
     * data = timestamp + "." + apiKey
     * accessToken = Base64(HMAC-SHA256(data, secretKey))
     */
    private HttpHeaders createAuthHeaders(String apiKey, String secretKey) {
        long timestamp = Instant.now().getEpochSecond();
        String data = timestamp + "." + apiKey;
        String accessToken = generateHmacBase64(data, secretKey);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("X-API-KEY", apiKey);
        headers.set("X-TIMESTAMP", String.valueOf(timestamp));
        headers.set("X-ACCESS-TOKEN", accessToken);
        return headers;
    }

    private String generateHmacBase64(String data, String secretKey) {
        try {
            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
            mac.init(new SecretKeySpec(secretKey.getBytes(UTF_8), HMAC_ALGORITHM));
            byte[] hash = mac.doFinal(data.getBytes(UTF_8));
            return Base64.getEncoder().encodeToString(hash);  // ★ Base64 (not hex)
        } catch (Exception e) {
            throw new RuntimeException("HMAC-SHA256 생성 실패", e);
        }
    }

    // ── API 메서드 ──

    /** 파트너 정보 조회 — siteId 획득 */
    public AximPartnerInfoResponse getPartnerInfo(String apiKey, String secretKey);

    /** Webhook URL 등록 */
    public AximPartnerInfoResponse updateWebhookUrl(String apiKey, String secretKey,
                                                     String webhookUrl);

    /** 입금 지갑 등록 */
    public AximDepositWalletResponse registerDepositWallet(String apiKey, String secretKey,
                                                            String chainType, String depositAddress,
                                                            String currencyType);

    /** 결제 생성 */
    public AximPaymentCreateResponse createPayment(String apiKey, String secretKey,
                                                    AximPaymentCreateRequest request);

    /** 결제 상태 조회 */
    public AximPaymentStatusResponse getPaymentStatus(String apiKey, String secretKey,
                                                       String transactionId);

    /** 결제 취소 */
    public AximPaymentResponse cancelPayment(String apiKey, String secretKey,
                                              String transactionId, String reason);

    /** 연결 정보 조회 */
    public AximConnectionInfoResponse getConnectionInfo(String apiKey, String secretKey,
                                                         String connectId);
}
```

**구현 시 주의사항**:
- `RestClient` (동기) 사용 — v2 컨벤션
- 모든 메서드에 `try-catch`로 `AximApiException` 래핑
- 로그: 요청/응답을 DEBUG 레벨로 기록
- 에러 응답 파싱: Axim은 `{ code, message, details }` 형태로 반환

**필요한 DTO (신규)**:

| 클래스 | 위치 | 용도 |
|--------|------|------|
| `AximPartnerInfoResponse` | common/client/dto/ | 파트너 정보 (partnerId=siteId, name, webhookUrl) |
| `AximPaymentCreateRequest` | common/client/dto/ | 결제 생성 요청 (connectWalletToken, priceKrw, amount, chainType) |
| `AximPaymentCreateResponse` | common/client/dto/ | 결제 생성 응답 (paymentId, walletAddress, status) |
| `AximPaymentStatusResponse` | common/client/dto/ | 결제 상태 (transactionId, status, txHash) |
| `AximDepositWalletRequest` | common/client/dto/ | 입금 지갑 등록 (chainType, depositAddress, currencyType) |
| `AximDepositWalletResponse` | common/client/dto/ | 입금 지갑 응답 (walletId, address) |
| `AximConnectionInfoResponse` | common/client/dto/ | 연결 정보 (connectId, connectWalletToken, wallets[]) |
| `AximUpdateWebhookUrlRequest` | common/client/dto/ | Webhook URL 수정 (webhookUrl) |
| `AximApiException` | common/exception/ | Axim API 호출 실패 예외 |

---

### 3-2. 설정 저장 시 API Key 검증 (partner-api)

**파일**: `partner-api/.../service/PartnerIntegrationService.java` — `updateAximSettings()` 수정

현재 DB만 저장하는 로직에 **Axim API Key 유효성 검증** 추가.

```java
public PartnerAximSettings updateAximSettings(Long partnerId, UpdateAximSettingsRequest request) {
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(partnerId);

    String apiKey = request.getApiKey();
    String apiSecret = request.getApiSecret();

    // ★ 신규: API Key 유효성 검증 (Axim 서버 호출)
    if (apiKey != null && apiSecret != null) {
        try {
            AximPartnerInfoResponse partnerInfo = aximPayClient.getPartnerInfo(apiKey, apiSecret);
            // siteId = Axim의 partnerId
            String siteId = String.valueOf(partnerInfo.getPartnerId());
            request.setSiteId(siteId);  // 자동 설정
            log.info("Axim API Key 검증 성공: partnerId={}, aximPartnerId={}", partnerId, siteId);
        } catch (Exception e) {
            log.error("Axim API Key 검증 실패: {}", e.getMessage());
            throw new BadRequestException("Axim API Key 검증 실패: " + e.getMessage());
        }
    }

    // 기존 DB 저장 로직 ...
    if (settings == null) {
        settings = PartnerAximSettings.builder()
                .partnerId(partnerId)
                .apiKey(apiKey)
                .apiSecretEnc(apiSecret)
                .siteId(request.getSiteId())
                .isEnabled(request.getIsEnabled() != null ? request.getIsEnabled() : false)
                .build();
        aximSettingsRepository.save(settings);
    } else {
        if (apiKey != null) settings.setApiKey(apiKey);
        if (apiSecret != null) settings.setApiSecretEnc(apiSecret);
        if (request.getSiteId() != null) settings.setSiteId(request.getSiteId());
        if (request.getIsEnabled() != null) settings.setIsEnabled(request.getIsEnabled());
        aximSettingsRepository.modify(settings);
    }
    return settings;
}
```

---

### 3-3. 활성화 시 Webhook URL + 마스터 지갑 등록 (partner-api)

**파일**: `partner-api/.../service/PartnerIntegrationService.java` — `activateAxim()` 신규 메서드

**파일**: `partner-api/.../controller/PartnerIntegrationController.java` — 엔드포인트 추가

```java
// ── Controller ──
@PostMapping(name = "Axim Pay 활성화", value = "/axim/activate")
public PartnerAximSettings activateAxim() {
    Long partnerId = getSession().getPartnerId();
    return partnerIntegrationService.activateAxim(partnerId);
}

@PostMapping(name = "Axim Pay 비활성화", value = "/axim/deactivate")
public PartnerAximSettings deactivateAxim() {
    Long partnerId = getSession().getPartnerId();
    return partnerIntegrationService.deactivateAxim(partnerId);
}
```

```java
// ── Service ──

/**
 * Axim Pay 활성화.
 *
 * 1. API Key/Secret 존재 확인
 * 2. Axim API로 파트너 정보 조회 (유효성 검증 + siteId)
 * 3. Webhook URL 등록
 * 4. 마스터 지갑 등록 (BSC, POLYGON, TRON)
 * 5. DB status → enabled=true
 */
public PartnerAximSettings activateAxim(Long partnerId) {
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(partnerId);
    if (settings == null) {
        throw new NotFoundException(ErrorCodes.AXIM_SETTINGS_NOT_FOUND);
    }

    String apiKey = settings.getApiKey();
    String secretKey = settings.getApiSecretEnc();

    if (apiKey == null || apiKey.isBlank() || secretKey == null || secretKey.isBlank()) {
        throw new BadRequestException("API Key와 Secret을 먼저 설정해주세요.");
    }

    try {
        // 1. 파트너 정보 조회 → siteId 획득
        AximPartnerInfoResponse partnerInfo = aximPayClient.getPartnerInfo(apiKey, secretKey);
        String siteId = String.valueOf(partnerInfo.getPartnerId());
        log.info("Axim 파트너 정보 확인: siteId={}, name={}", siteId, partnerInfo.getName());

        // 2. Webhook URL 등록
        //    ★ 운영: https://api.cryptoments.cc/webhooks/axim/{partnerId}
        String webhookUrl = buildWebhookUrl(partnerId);
        aximPayClient.updateWebhookUrl(apiKey, secretKey, webhookUrl);
        log.info("Axim Webhook URL 등록 완료: {}", webhookUrl);

        // 3. 마스터 지갑 등록 (체인별)
        registerMasterWallets(partnerId, apiKey, secretKey);

        // 4. DB 업데이트
        settings.setSiteId(siteId);
        settings.setWebhookUrl(webhookUrl);
        settings.setIsEnabled(true);
        aximSettingsRepository.modify(settings);

        log.info("Axim Pay 활성화 완료: partnerId={}", partnerId);
        return settings;

    } catch (AximApiException e) {
        log.error("Axim 활성화 실패: {}", e.getMessage());
        throw new BadRequestException("Axim API 연동 실패: " + e.getMessage());
    }
}

/**
 * Webhook URL 생성.
 * 환경변수 또는 설정에서 base URL 가져오기.
 */
private String buildWebhookUrl(Long partnerId) {
    // application.yml: cryptoments.webhook.base-url
    return webhookBaseUrl + "/webhooks/axim/" + partnerId;
}

/**
 * 마스터 지갑 등록 (BSC, POLYGON, TRON).
 * wallet_assignments에서 MASTER 타입 지갑 주소를 조회하여 Axim에 등록.
 */
private void registerMasterWallets(Long partnerId, String apiKey, String secretKey) {
    // v2에서는 wallet_assignments + wallet_addresses에서 MASTER 지갑 조회
    // 체인별로 Axim에 등록
    String[] chains = {"BSC", "POLYGON", "TRON"};
    String currency = "USDT";

    for (String chain : chains) {
        // 파트너의 MASTER 지갑 주소 조회
        String masterAddress = walletService.getMasterWalletAddress(partnerId, chain);
        if (masterAddress == null) {
            log.warn("마스터 지갑 미설정: partnerId={}, chain={}", partnerId, chain);
            continue;  // 또는 throw — 정책에 따라
        }

        try {
            aximPayClient.registerDepositWallet(apiKey, secretKey, chain, masterAddress, currency);
            log.info("Axim 입금 지갑 등록: chain={}, address={}", chain, masterAddress);
        } catch (Exception e) {
            log.error("Axim 지갑 등록 실패: chain={}, error={}", chain, e.getMessage());
            throw new BadRequestException(chain + " 체인 지갑 등록 실패: " + e.getMessage());
        }
    }
}
```

**application.yml 추가 설정**:

```yaml
cryptoments:
  webhook:
    base-url: ${WEBHOOK_BASE_URL:https://api.cryptoments.cc}
```

---

### 3-4. init-info 실시간 조회 + connectId 생성 (open-api)

**파일**: `open-api/.../controller/widget/AximController.java` — `getInitInfo()` 수정

```java
@GetMapping(name = "Axim 초기화 정보 조회", value = "/init-info")
public AximInitInfoResponse getInitInfo(
        @RequestParam(required = false) String partnerUserId,
        OpenApiSessionData session) {

    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());

    if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
        return AximInitInfoResponse.builder()
                .enabled(false)
                .status("NOT_CONFIGURED")
                .build();
    }

    // ★ Axim API 실시간 호출 → siteId, partnerName 최신 정보
    try {
        AximPartnerInfoResponse partnerInfo =
                aximPayClient.getPartnerInfo(settings.getApiKey(), settings.getApiSecretEnc());

        String siteId = String.valueOf(partnerInfo.getPartnerId());
        String connectId = generateConnectId(partnerInfo.getPartnerId(), partnerUserId);

        // 진행 중인 결제 확인 (선택)
        Long pendingPaymentId = findPendingPayment(session.getPartnerId(), partnerUserId);

        return AximInitInfoResponse.builder()
                .enabled(true)
                .siteId(siteId)
                .connectId(connectId)
                .partnerName(partnerInfo.getName())
                .apiKey(partnerInfo.getApiKey())
                .status("ACTIVE")
                .pendingPaymentId(pendingPaymentId)
                .build();

    } catch (Exception e) {
        log.error("Axim 초기화 정보 조회 실패: {}", e.getMessage());
        return AximInitInfoResponse.builder()
                .enabled(true)
                .siteId(settings.getSiteId())  // DB fallback
                .status("API_ERROR")
                .build();
    }
}

/**
 * connectId 생성 — partnerId + partnerUserId를 hex 인코딩.
 * Axim Widget에서 사용자를 식별하는 키.
 */
private String generateConnectId(Long aximPartnerId, String partnerUserId) {
    if (partnerUserId == null) return null;
    String input = aximPartnerId + ":" + partnerUserId;
    byte[] bytes = input.getBytes(StandardCharsets.UTF_8);
    StringBuilder hex = new StringBuilder();
    for (byte b : bytes) {
        hex.append(String.format("%02x", b));
    }
    return hex.toString();
}
```

**AximInitInfoResponse DTO 수정** (open-api):

```java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class AximInitInfoResponse {
    /** Axim 활성화 여부 */
    private boolean enabled;
    /** Axim Site ID (= Axim partnerId) */
    private String siteId;
    /** Axim 연결 고유 값 (hex(partnerId:partnerUserId)) */
    private String connectId;
    /** Axim 파트너명 */
    private String partnerName;
    /** Axim API Key (클라이언트 측 호출용) */
    private String apiKey;
    /** 상태: ACTIVE, NOT_CONFIGURED, API_ERROR */
    private String status;
    /** 현재 PENDING 중인 결제 ID (있으면) */
    private Long pendingPaymentId;
}
```

---

### 3-5. Axim Webhook 수신 컨트롤러 (open-api)

**파일**: `open-api/.../controller/webhook/AximWebhookController.java` — 신규 생성

```java
package com.cryptoments.openapi.controller.webhook;

/**
 * Axim Pay Webhook 수신 컨트롤러.
 * Axim 서버가 결제/연결 상태 변경 시 호출하는 콜백 엔드포인트.
 *
 * URL: POST /webhooks/axim/{partnerId}
 * 인증: Axim-Signature 헤더 (HMAC-SHA256(payload, secretKey) → Base64)
 */
@RestController
@RequestMapping("/webhooks/axim")
public class AximWebhookController {

    private final PartnerAximSettingsRepository aximSettingsRepository;
    private final AximService aximService;
    private final ObjectMapper objectMapper;

    /**
     * Axim Webhook 수신.
     *
     * @param partnerId 파트너 ID (URL path)
     * @param signature Axim-Signature 헤더
     * @param payload 원본 JSON 페이로드
     */
    @PostMapping("/{partnerId}")
    public ResponseEntity<String> receiveWebhook(
            @PathVariable Long partnerId,
            @RequestHeader(value = "Axim-Signature") String signature,
            @RequestBody String payload) {

        log.info("Axim Webhook 수신: partnerId={}", partnerId);

        // 1. 파트너 Axim 설정 조회
        PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(partnerId);
        if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
            return ResponseEntity.status(401).body("Axim not configured");
        }

        // 2. 서명 검증
        String secretKey = settings.getApiSecretEnc();
        if (!verifySignature(payload, signature, secretKey)) {
            log.warn("Axim Webhook 서명 검증 실패: partnerId={}", partnerId);
            return ResponseEntity.status(401).body("Invalid signature");
        }

        // 3. 이벤트 파싱 및 처리
        try {
            AximWebhookEvent event = objectMapper.readValue(payload, AximWebhookEvent.class);
            processEvent(partnerId, event);
            return ResponseEntity.ok("OK");
        } catch (Exception e) {
            log.error("Axim Webhook 처리 실패: {}", e.getMessage(), e);
            return ResponseEntity.status(500).body("Processing failed");
        }
    }

    /**
     * 서명 검증: HMAC-SHA256(payload, secretKey) → Base64 == Axim-Signature
     */
    private boolean verifySignature(String payload, String signature, String secretKey) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secretKey.getBytes(UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(payload.getBytes(UTF_8));
            String expected = Base64.getEncoder().encodeToString(hash);
            return MessageDigest.isEqual(
                    expected.getBytes(UTF_8), signature.getBytes(UTF_8));
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 이벤트 타입별 처리.
     */
    private void processEvent(Long partnerId, AximWebhookEvent event) {
        String eventType = event.getEventType();
        log.info("Axim 이벤트 처리: type={}, eventId={}", eventType, event.getEventId());

        switch (eventType) {
            // ── 연결 이벤트 ──
            case "connection.succeeded":
                handleConnectionSucceeded(partnerId, event);
                break;
            case "connection.failed":
                handleConnectionFailed(partnerId, event);
                break;
            case "connection.revoked":
                handleConnectionRevoked(partnerId, event);
                break;

            // ── 결제 이벤트 ──
            case "payment.succeeded":
                handlePaymentSucceeded(partnerId, event);
                break;
            case "payment.failed":
                handlePaymentFailed(partnerId, event);
                break;
            case "payment.canceled":
                handlePaymentCanceled(partnerId, event);
                break;
            case "payment.expired":
                handlePaymentExpired(partnerId, event);
                break;

            default:
                log.warn("알 수 없는 Axim 이벤트: type={}", eventType);
        }
    }
}
```

**AximWebhookEvent DTO** (open-api 또는 common):

```java
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class AximWebhookEvent {
    /** 이벤트 고유 ID */
    private String eventId;
    /** 이벤트 타입 (connection.succeeded, payment.succeeded 등) */
    private String eventType;
    /** 타임스탬프 */
    private String timestamp;
    /** 이벤트 데이터 (JSON) — eventType에 따라 다른 구조 */
    private Map<String, Object> data;
}
```

**SecurityConfig에 Webhook 경로 permitAll 추가**:

```java
// open-api SecurityConfig
.requestMatchers("/webhooks/**").permitAll()
```

---

### 3-6. Webhook 이벤트 핸들러 구현 (open-api 또는 core)

각 이벤트 핸들러의 핵심 로직:

#### connection.succeeded — 외부 지갑 연결

```java
private void handleConnectionSucceeded(Long partnerId, AximWebhookEvent event) {
    Map<String, Object> data = event.getData();
    String connectionId = (String) data.get("connectionId");
    String connectWalletToken = (String) data.get("connectWalletToken");
    String siteId = (String) data.get("siteId");

    // connectId에서 partnerUserId 추출 (hex 디코딩)
    String connectId = (String) data.get("connectId");
    String partnerUserId = decodePartnerUserId(connectId);

    // 지갑 주소 정보 — Axim API로 상세 조회
    String walletAddresses = fetchWalletAddresses(connectionId);

    // external_wallets 저장
    aximService.connectWallet(partnerId, partnerUserId,
            connectionId, walletAddresses, siteId, connectWalletToken);

    log.info("Axim 지갑 연결 완료: partnerId={}, connectionId={}", partnerId, connectionId);
}
```

#### payment.succeeded — 결제 확정

```java
private void handlePaymentSucceeded(Long partnerId, AximWebhookEvent event) {
    Map<String, Object> data = event.getData();
    String aximPaymentId = String.valueOf(data.get("paymentId"));

    AximPayment payment = aximService.confirmPayment(aximPaymentId);
    log.info("Axim 결제 확정: paymentId={}", aximPaymentId);

    // 파트너 콜백 발송 (선택)
    // notificationService.sendPaymentConfirmed(partnerId, payment);
}
```

#### 기타 이벤트

| 이벤트 | 처리 |
|--------|------|
| `connection.failed` | 로그만 기록 |
| `connection.revoked` | `aximService.revokeWallet(externalWalletId)` |
| `payment.failed` | `aximService.updatePaymentStatus(code, id, FAILED)` |
| `payment.canceled` | `aximService.updatePaymentStatus(code, id, CANCELED)` |
| `payment.expired` | `aximService.updatePaymentStatus(code, id, EXPIRED)` |

---

### 3-7. 결제 생성 시 Axim API 호출 (open-api)

**파일**: `open-api/.../controller/widget/AximController.java` — `createPayment()` 수정

현재 DB INSERT만 하고 있으나, **Axim Pay API에 실제 결제를 생성**해야 한다.

```java
@PostMapping(name = "Axim 결제 생성", value = "/payments")
public AximPaymentResponse createPayment(@RequestBody AximPaymentRequest request,
                                          OpenApiSessionData session) {
    // 1. Axim 설정 확인
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());
    if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
        throw new NotFoundException(ErrorCodes.AXIM_SETTINGS_NOT_FOUND);
    }

    // 2. 외부 지갑에서 connectWalletToken 조회
    ExternalWallet wallet = externalWalletRepository
            .findByPartnerIdAndPartnerUserId(session.getPartnerId(), request.getPartnerUserId())
            .stream()
            .filter(w -> w.getStatus() == ExternalWalletStatus.CONNECTED)
            .findFirst()
            .orElseThrow(() -> new NotFoundException(ErrorCodes.WALLET_NOT_FOUND));

    // 3. ★ Axim Pay API 호출 — 실제 결제 생성
    AximPaymentCreateRequest aximRequest = AximPaymentCreateRequest.builder()
            .connectWalletToken(wallet.getConnectWalletToken())
            .priceKrw(request.getPriceKrw())
            .amount(request.getAmount())
            .chainType(request.getChainType())
            .feeAmount(request.getFeeAmount())
            .orderId(request.getOrderId())
            .build();

    AximPaymentCreateResponse aximResponse = aximPayClient.createPayment(
            settings.getApiKey(), settings.getApiSecretEnc(), aximRequest);

    // 4. DB 저장 (aximPaymentId 포함)
    AximPayment payment = aximService.requestPayment(
            session.getPartnerId(), request.getPartnerUserId(),
            wallet.getId(), request.getAmount(),
            /* currencyId */ null, /* networkId */ null,
            request.getOrderId());

    // Axim 응답으로 업데이트
    aximService.updatePaymentStatus(
            payment.getPaymentCode(),
            String.valueOf(aximResponse.getPaymentId()),
            AximPaymentStatus.PENDING);

    return AximPaymentResponse.builder()
            .paymentId(payment.getId())
            .paymentCode(payment.getPaymentCode())
            .aximPaymentId(aximResponse.getPaymentId())
            .walletAddress(aximResponse.getWalletAddress())
            .amount(aximResponse.getAmount())
            .status(aximResponse.getStatus())
            .chainType(aximResponse.getChainType())
            .orderId(request.getOrderId())
            .build();
}
```

---

## 4. 구현 순서 (권장)

```
Phase 1: 기반 (common)
  ├─ 3-1. AximPayClient + DTO 생성
  └─ 3-1. AximApiException 예외 생성

Phase 2: 설정 + 활성화 (partner-api)
  ├─ 3-2. updateAximSettings에 API Key 검증 추가
  └─ 3-3. activateAxim / deactivateAxim 구현

Phase 3: Widget 연동 (open-api)
  ├─ 3-4. init-info 실시간 조회 + connectId
  ├─ 3-5. AximWebhookController 신규
  ├─ 3-6. Webhook 이벤트 핸들러
  └─ 3-7. 결제 생성 시 Axim API 호출

Phase 4: 테스트
  ├─ Axim API Key 저장 → getPartnerInfo 검증
  ├─ 활성화 → Webhook URL 등록 확인
  ├─ Widget init-info → siteId + connectId 반환 확인
  ├─ Axim Webhook 시뮬레이션 (connection.succeeded)
  └─ 결제 생성 → Axim API 호출 → Webhook 수신 → 결제 확정
```

---

## 5. 환경 설정

### application.yml 추가

```yaml
# partner-api, open-api 모두
cryptoments:
  webhook:
    base-url: ${WEBHOOK_BASE_URL:https://api.cryptoments.cc}

external:
  axim-pay:
    url: ${AXIM_PAY_URL:https://pay.axim.one}
```

### .env 파일 추가

```bash
# app-01 (.env.open-api)
WEBHOOK_BASE_URL=https://api.cryptoments.cc
AXIM_PAY_URL=https://pay.axim.one
```

---

## 6. v1 → v2 매핑 참조

| v1 클래스 | v2 대응 | 비고 |
|-----------|---------|------|
| `AximPayClient` (common-lib) | `AximPayClient` (common) | RestTemplate → RestClient |
| `AximSettingsService` (admin-api) | `PartnerIntegrationService` (partner-api) | activate/deactivate 추가 |
| `AximService` (common-lib) | `AximService` (core) | 이미 존재, 일부 수정 필요 |
| `AximWebhookController` (widget-api) | `AximWebhookController` (open-api) | 신규 생성 |
| `AximSettingsController` (admin-api) | `PartnerIntegrationController` (partner-api) | 엔드포인트 추가 |
| `PartnerAximSettings` (entity) | `PartnerAximSettings` (entity) | ✅ 이미 존재 |
| 19개 DTO (common-lib) | common/client/dto/ | 필요한 것만 선별 생성 |

---

## 7. 주의사항

1. **HMAC 인코딩 차이**: Cryptoments 내부 = **hex**, Axim Pay API = **Base64**. 혼용 금지.
2. **siteId = Axim의 partnerId**: DB에 저장하되, init-info에서는 실시간 조회 우선.
3. **Webhook URL 패턴**: `/webhooks/axim/{partnerId}` — partnerId로 어떤 파트너의 콜백인지 식별.
4. **Webhook 엔드포인트는 인증 제외**: SecurityConfig에서 `/webhooks/**` permitAll. 대신 `Axim-Signature` 헤더로 검증.
5. **connectId 인코딩**: `hex(aximPartnerId + ":" + partnerUserId)` — v1과 동일 방식.
6. **마스터 지갑 등록**: 파트너에 MASTER 지갑이 할당되어 있어야 활성화 가능. 미할당 시 에러.
7. **에러 시 rollback**: 활성화 도중 실패하면 DB 상태를 ACTIVE로 변경하지 않음 (트랜잭션).
