[매입] mg_recv_svc → 전문수신 @Service #8

Open
forge-bot wants to merge 9 commits from forge/ACM-MG-004-attempt-3-run-56504178208a into main
9 changed files with 861 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# ACM-MG-004-attempt-3-run-56504178208a
Forge 이슈 작업 브랜치 `forge/ACM-MG-004-attempt-3-run-56504178208a`.

View file

@ -0,0 +1,85 @@
package com.klaro.acquirecore.acquiring.dto;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
/**
* TX message domain object.
* Represents an acquiring transaction message.
*/
public class TxMessage implements Serializable {
private static final long serialVersionUID = 1L;
private String header;
private byte[] rawBody;
// Core fields
private String txId;
private String merchantId;
private String terminalId;
private String amount;
private String status;
private String tranDate;
private String tranTime;
private String authNo;
private String cardNo;
private String vanMsg;
// Extra fields (dynamic)
private Map<String, String> extra = new HashMap<>();
public TxMessage() {}
public String getHeader() { return header; }
public void setHeader(String header) { this.header = header; }
public byte[] getRawBody() { return rawBody; }
public void setRawBody(byte[] rawBody) { this.rawBody = rawBody; }
public String getTxId() { return txId; }
public void setTxId(String txId) { this.txId = txId; }
public String getMerchantId() { return merchantId; }
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
public String getTerminalId() { return terminalId; }
public void setTerminalId(String terminalId) { this.terminalId = terminalId; }
public String getAmount() { return amount; }
public void setAmount(String amount) { this.amount = amount; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getTranDate() { return tranDate; }
public void setTranDate(String tranDate) { this.tranDate = tranDate; }
public String getTranTime() { return tranTime; }
public void setTranTime(String tranTime) { this.tranTime = tranTime; }
public String getAuthNo() { return authNo; }
public void setAuthNo(String authNo) { this.authNo = authNo; }
public String getCardNo() { return cardNo; }
public void setCardNo(String cardNo) { this.cardNo = cardNo; }
public String getVanMsg() { return vanMsg; }
public void setVanMsg(String vanMsg) { this.vanMsg = vanMsg; }
public Map<String, String> getExtra() { return extra; }
public void setExtra(Map<String, String> extra) { this.extra = extra; }
public void setExtra(String key, String value) { this.extra.put(key, value); }
@Override
public String toString() {
return "TxMessage{" +
"txId='" + txId + '\'' +
", merchantId='" + merchantId + '\'' +
", terminalId='" + terminalId + '\'' +
", amount='" + amount + '\'' +
", status='" + status + '\'' +
'}';
}
}

View file

@ -0,0 +1,49 @@
package com.klaro.acquirecore.acquiring.dto;
import java.io.Serializable;
/**
* TX processing result.
*/
public class TxResult implements Serializable {
private static final long serialVersionUID = 1L;
private String txId;
private String code;
private String message;
private boolean success;
private TxMessage response;
public TxResult() {}
public TxResult(String txId, boolean success, String code, String message) {
this.txId = txId;
this.success = success;
this.code = code;
this.message = message;
}
public static TxResult ok(String txId, String message) {
return new TxResult(txId, true, "0000", message);
}
public static TxResult error(String txId, String code, String message) {
return new TxResult(txId, false, code, message);
}
public String getTxId() { return txId; }
public void setTxId(String txId) { this.txId = txId; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public TxMessage getResponse() { return response; }
public void setResponse(TxMessage response) { this.response = response; }
}

View file

@ -0,0 +1,40 @@
package com.klaro.acquirecore.acquiring.repository;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
/**
* Repository interface for TX data access.
* Stub implementation - actual DB access to be implemented.
*/
public interface TxRepository {
/**
* Check if merchant is active.
*/
boolean isMerchantActive(String merchantId);
/**
* Check if terminal is active for given merchant.
*/
boolean isTerminalActive(String merchantId, String terminalId);
/**
* Save transaction record.
*/
void saveTransaction(TxMessage msg);
/**
* Check if transaction exists.
*/
boolean transactionExists(String txId);
/**
* Get transaction status.
*/
String getTransactionStatus(String txId);
/**
* Update transaction status.
*/
void updateTransactionStatus(String txId, String status);
}

View file

@ -0,0 +1,70 @@
package com.klaro.acquirecore.acquiring.repository;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Stub implementation of TxRepository.
* In-memory storage for development/testing.
*/
@Repository
public class TxRepositoryStub implements TxRepository {
private static final Logger log = LoggerFactory.getLogger(TxRepositoryStub.class);
private final Map<String, TxMessage> transactions = new ConcurrentHashMap<>();
private final Map<String, Boolean> activeMerchants = new ConcurrentHashMap<>();
private final Map<String, Boolean> activeTerminals = new ConcurrentHashMap<>();
public TxRepositoryStub() {
// Initialize with test data
activeMerchants.put("M001", true);
activeMerchants.put("M002", true);
activeTerminals.put("M001|T001", true);
activeTerminals.put("M001|T002", true);
activeTerminals.put("M002|T001", true);
}
@Override
public boolean isMerchantActive(String merchantId) {
return activeMerchants.getOrDefault(merchantId, true);
}
@Override
public boolean isTerminalActive(String merchantId, String terminalId) {
return activeTerminals.getOrDefault(merchantId + "|" + terminalId, true);
}
@Override
public void saveTransaction(TxMessage msg) {
if (msg.getTxId() != null) {
transactions.put(msg.getTxId(), msg);
log.debug("Saved transaction: {}", msg.getTxId());
}
}
@Override
public boolean transactionExists(String txId) {
return transactions.containsKey(txId);
}
@Override
public String getTransactionStatus(String txId) {
TxMessage msg = transactions.get(txId);
return msg != null ? msg.getStatus() : null;
}
@Override
public void updateTransactionStatus(String txId, String status) {
TxMessage msg = transactions.get(txId);
if (msg != null) {
msg.setStatus(status);
log.debug("Updated transaction {} status to {}", txId, status);
}
}
}

View file

@ -0,0 +1,158 @@
package com.klaro.acquirecore.acquiring.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
import org.springframework.stereotype.Component;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* Codec for TX message packing/unpacking.
* Handles binary protocol domain object conversion.
*/
@Component
public class TxMessageCodec {
private static final DateTimeFormatter DT_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private static final String ENCODING = "EUC-KR";
private final ObjectMapper objectMapper;
public TxMessageCodec(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Unpack binary message to TxMessage domain object.
* Format: HEADER(20) + BODY(n)
*/
public TxMessage unpack(byte[] raw) {
if (raw == null || raw.length < 20) {
throw new IllegalArgumentException("Invalid message: too short");
}
ByteBuffer buf = ByteBuffer.wrap(raw);
// HEADER: 20 bytes
byte[] headerBytes = new byte[20];
buf.get(headerBytes);
String header = new String(headerBytes, StandardCharsets.ISO_8859_1).trim();
// BODY: remaining bytes
byte[] bodyBytes = new byte[raw.length - 20];
buf.get(bodyBytes);
TxMessage msg = new TxMessage();
msg.setHeader(header);
msg.setRawBody(bodyBytes);
// Parse body as JSON or delimited fields
parseBody(msg, bodyBytes);
return msg;
}
private void parseBody(TxMessage msg, byte[] body) {
String bodyStr = new String(body, StandardCharsets.ISO_8859_1);
try {
JsonNode node = objectMapper.readTree(bodyStr);
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
obj.fields().forEachRemaining(e -> {
String key = e.getKey();
JsonNode val = e.getValue();
if (val.isTextual()) {
setField(msg, key, val.asText());
} else if (val.isNumber()) {
setNumericField(msg, key, val.asLong());
}
});
}
} catch (Exception e) {
// Fallback: delimited parse
parseDelimited(msg, bodyStr);
}
}
private void parseDelimited(TxMessage msg, String body) {
String[] fields = body.split("\\|");
for (int i = 0; i < fields.length; i++) {
String[] kv = fields[i].split("=", 2);
if (kv.length == 2) {
setField(msg, kv[0].trim(), kv[1].trim());
}
}
}
private void setField(TxMessage msg, String key, String value) {
switch (key.toUpperCase()) {
case "TXID": msg.setTxId(value); break;
case "MERCHANTID": msg.setMerchantId(value); break;
case "TERMINALID": msg.setTerminalId(value); break;
case "AMOUNT": msg.setAmount(value); break;
case "STATUS": msg.setStatus(value); break;
case "TRANDATE": msg.setTranDate(value); break;
case "TRANTIME": msg.setTranTime(value); break;
case "AUTHNO": msg.setAuthNo(value); break;
case "CARDNO": msg.setCardNo(value); break;
case "VANMSG": msg.setVanMsg(value); break;
default: msg.setExtra(key, value);
}
}
private void setNumericField(TxMessage msg, String key, long value) {
if ("AMOUNT".equalsIgnoreCase(key)) {
msg.setAmount(String.valueOf(value));
}
}
/**
* Pack TxMessage to binary format.
*/
public byte[] pack(TxMessage msg) {
byte[] body = packBody(msg);
byte[] header = buildHeader(msg, body.length);
ByteBuffer buf = ByteBuffer.allocate(header.length + body.length);
buf.put(header);
buf.put(body);
return buf.array();
}
private byte[] buildHeader(TxMessage msg, int bodyLen) {
String header = String.format("%-20s",
msg.getTxId() != null ? msg.getTxId() : UUID.randomUUID().toString().substring(0, 8));
return header.getBytes(StandardCharsets.ISO_8859_1);
}
private byte[] packBody(TxMessage msg) {
try {
ObjectNode node = objectMapper.createObjectNode();
if (msg.getTxId() != null) node.put("TXID", msg.getTxId());
if (msg.getMerchantId() != null) node.put("MERCHANTID", msg.getMerchantId());
if (msg.getTerminalId() != null) node.put("TERMINALID", msg.getTerminalId());
if (msg.getAmount() != null) node.put("AMOUNT", msg.getAmount());
if (msg.getStatus() != null) node.put("STATUS", msg.getStatus());
if (msg.getTranDate() != null) node.put("TRANDATE", msg.getTranDate());
if (msg.getTranTime() != null) node.put("TRANTIME", msg.getTranTime());
if (msg.getAuthNo() != null) node.put("AUTHNO", msg.getAuthNo());
if (msg.getCardNo() != null) node.put("CARDNO", maskCardNo(msg.getCardNo()));
if (msg.getVanMsg() != null) node.put("VANMSG", msg.getVanMsg());
msg.getExtra().forEach(node::put);
return objectMapper.writeValueAsString(node).getBytes(StandardCharsets.ISO_8859_1);
} catch (Exception e) {
throw new RuntimeException("Failed to pack message body", e);
}
}
private String maskCardNo(String cardNo) {
if (cardNo == null || cardNo.length() < 10) return cardNo;
return cardNo.substring(0, 6) + "****" + cardNo.substring(cardNo.length() - 4);
}
}

View file

@ -0,0 +1,191 @@
package com.klaro.acquirecore.acquiring.service;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
import com.klaro.acquirecore.acquiring.dto.TxResult;
import com.klaro.acquirecore.acquiring.repository.TxRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* TX_SERVICE: Main service for receiving and processing acquiring messages.
* Migrated from legacy/app/online/mg_recv_svc.pgc
*/
@Service
public class TxMessageReceiveService {
private static final Logger log = LoggerFactory.getLogger(TxMessageReceiveService.class);
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final TxMessageCodec codec;
private final TxRepository txRepository;
public TxMessageReceiveService(TxMessageCodec codec, TxRepository txRepository) {
this.codec = codec;
this.txRepository = txRepository;
}
/**
* Receive and process raw binary message.
* @param raw raw message bytes
* @return processing result
*/
public TxResult receive(byte[] raw) {
String traceId = UUID.randomUUID().toString().substring(0, 8);
log.info("[{}] Receiving message, size={}", traceId, raw != null ? raw.length : 0);
try {
// 1. Unpack message
TxMessage msg = codec.unpack(raw);
log.debug("[{}] Unpacked: {}", traceId, msg);
// 2. Validate required fields
TxResult validation = validate(msg, traceId);
if (validation != null) {
return validation;
}
// 3. Process transaction
return processTransaction(msg, traceId);
} catch (Exception e) {
log.error("[{}] Processing error: {}", traceId, e.getMessage(), e);
return TxResult.error(traceId, "9999", "Processing error: " + e.getMessage());
}
}
/**
* Validate message fields.
*/
private TxResult validate(TxMessage msg, String traceId) {
if (msg.getMerchantId() == null || msg.getMerchantId().isBlank()) {
log.warn("[{}] Missing merchantId", traceId);
return TxResult.error(traceId, "1001", "Missing merchantId");
}
if (msg.getTerminalId() == null || msg.getTerminalId().isBlank()) {
log.warn("[{}] Missing terminalId", traceId);
return TxResult.error(traceId, "1002", "Missing terminalId");
}
if (msg.getAmount() == null || msg.getAmount().isBlank()) {
log.warn("[{}] Missing amount", traceId);
return TxResult.error(traceId, "1003", "Missing amount");
}
// Validate amount is numeric
try {
Long.parseLong(msg.getAmount());
} catch (NumberFormatException e) {
log.warn("[{}] Invalid amount format: {}", traceId, msg.getAmount());
return TxResult.error(traceId, "1004", "Invalid amount format");
}
return null;
}
/**
* Process the transaction.
*/
private TxResult processTransaction(TxMessage msg, String traceId) {
try {
// 1. Check merchant status via repository stub
if (!txRepository.isMerchantActive(msg.getMerchantId())) {
log.warn("[{}] Merchant not active: {}", traceId, msg.getMerchantId());
return TxResult.error(traceId, "2001", "Merchant not active");
}
// 2. Check terminal status via repository stub
if (!txRepository.isTerminalActive(msg.getMerchantId(), msg.getTerminalId())) {
log.warn("[{}] Terminal not active: {}/{}", traceId, msg.getMerchantId(), msg.getTerminalId());
return TxResult.error(traceId, "2002", "Terminal not active");
}
// 3. Generate transaction ID if not present
if (msg.getTxId() == null || msg.getTxId().isBlank()) {
msg.setTxId(generateTxId());
}
// 4. Set transaction timestamp
String now = LocalDateTime.now().format(DT_FMT);
msg.setTranDate(now.substring(0, 8));
msg.setTranTime(now.substring(8));
// 5. Save transaction via repository stub
txRepository.saveTransaction(msg);
// 6. Process based on status
String status = msg.getStatus() != null ? msg.getStatus().toUpperCase() : "REQ";
switch (status) {
case "REQ":
case "APPROVE":
return handleApproval(msg, traceId);
case "CANCEL":
case "REFUND":
return handleCancellation(msg, traceId);
default:
log.info("[{}] Unknown status, defaulting to approval", traceId);
return handleApproval(msg, traceId);
}
} catch (Exception e) {
log.error("[{}] Transaction processing error: {}", traceId, e.getMessage(), e);
return TxResult.error(traceId, "9999", "Transaction processing failed");
}
}
/**
* Handle approval transaction.
*/
private TxResult handleApproval(TxMessage msg, String traceId) {
log.info("[{}] Processing approval for merchant={}, amount={}",
traceId, msg.getMerchantId(), msg.getAmount());
// Generate auth number
String authNo = generateAuthNo();
msg.setAuthNo(authNo);
msg.setStatus("APPROVED");
// Update via repository
txRepository.updateTransactionStatus(msg.getTxId(), "APPROVED");
log.info("[{}] Approval complete, authNo={}", traceId, authNo);
return TxResult.ok(msg.getTxId(), "Approval complete");
}
/**
* Handle cancellation/refund transaction.
*/
private TxResult handleCancellation(TxMessage msg, String traceId) {
log.info("[{}] Processing cancellation for txId={}", traceId, msg.getTxId());
// Check if original transaction exists
if (!txRepository.transactionExists(msg.getTxId())) {
log.warn("[{}] Original transaction not found: {}", traceId, msg.getTxId());
return TxResult.error(traceId, "3001", "Original transaction not found");
}
// Check if already cancelled
String currentStatus = txRepository.getTransactionStatus(msg.getTxId());
if ("CANCELLED".equals(currentStatus)) {
log.warn("[{}] Already cancelled: {}", traceId, msg.getTxId());
return TxResult.error(traceId, "3002", "Already cancelled");
}
// Process cancellation
msg.setStatus("CANCELLED");
txRepository.updateTransactionStatus(msg.getTxId(), "CANCELLED");
log.info("[{}] Cancellation complete", traceId);
return TxResult.ok(msg.getTxId(), "Cancellation complete");
}
private String generateTxId() {
return "TX" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ String.format("%04d", (int)(Math.random() * 10000));
}
private String generateAuthNo() {
return String.format("%06d", (int)(Math.random() * 1000000));
}
}

View file

@ -0,0 +1,107 @@
package com.klaro.acquirecore.acquiring.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class TxMessageCodecTest {
private TxMessageCodec codec;
@BeforeEach
void setUp() {
codec = new TxMessageCodec(new ObjectMapper());
}
@Test
void unpack_validJsonMessage_parsesCorrectly() {
// Given
String body = "{\"TXID\":\"TX001\",\"MERCHANTID\":\"M001\",\"TERMINALID\":\"T001\",\"AMOUNT\":\"10000\"}";
byte[] raw = buildRawMessage("HEADER", body);
// When
TxMessage msg = codec.unpack(raw);
// Then
assertEquals("TX001", msg.getTxId());
assertEquals("M001", msg.getMerchantId());
assertEquals("T001", msg.getTerminalId());
assertEquals("10000", msg.getAmount());
}
@Test
void unpack_delimitedMessage_parsesCorrectly() {
// Given
String body = "TXID=TX002|MERCHANTID=M002|TERMINALID=T002|AMOUNT=20000";
byte[] raw = buildRawMessage("HEADER", body);
// When
TxMessage msg = codec.unpack(raw);
// Then
assertEquals("TX002", msg.getTxId());
assertEquals("M002", msg.getMerchantId());
assertEquals("T002", msg.getTerminalId());
assertEquals("20000", msg.getAmount());
}
@Test
void unpack_tooShortMessage_throwsException() {
// Given
byte[] raw = "short".getBytes(StandardCharsets.ISO_8859_1);
// When/Then
assertThrows(IllegalArgumentException.class, () -> codec.unpack(raw));
}
@Test
void pack_unpackedMessage_producesValidBytes() {
// Given
TxMessage msg = new TxMessage();
msg.setTxId("TX003");
msg.setMerchantId("M003");
msg.setTerminalId("T003");
msg.setAmount("30000");
msg.setStatus("REQ");
// When
byte[] packed = codec.pack(msg);
// Then
assertNotNull(packed);
assertTrue(packed.length >= 20);
// Verify can unpack back
TxMessage unpacked = codec.unpack(packed);
assertEquals("TX003", unpacked.getTxId());
assertEquals("M003", unpacked.getMerchantId());
}
@Test
void unpack_packRoundTrip_preservesData() {
// Given
String body = "{\"TXID\":\"TX004\",\"MERCHANTID\":\"M004\",\"AMOUNT\":\"40000\",\"AUTHNO\":\"123456\"}";
byte[] raw = buildRawMessage("HEADER", body);
// When
TxMessage original = codec.unpack(raw);
byte[] repacked = codec.pack(original);
TxMessage roundTrip = codec.unpack(repacked);
// Then
assertEquals(original.getTxId(), roundTrip.getTxId());
assertEquals(original.getMerchantId(), roundTrip.getMerchantId());
assertEquals(original.getAmount(), roundTrip.getAmount());
assertEquals(original.getAuthNo(), roundTrip.getAuthNo());
}
private byte[] buildRawMessage(String header, String body) {
String hdr = String.format("%-20s", header);
return (hdr + body).getBytes(StandardCharsets.ISO_8859_1);
}
}

View file

@ -0,0 +1,158 @@
package com.klaro.acquirecore.acquiring.service;
import com.klaro.acquirecore.acquiring.dto.TxMessage;
import com.klaro.acquirecore.acquiring.dto.TxResult;
import com.klaro.acquirecore.acquiring.repository.TxRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TxMessageReceiveServiceTest {
@Mock
private TxRepository txRepository;
private TxMessageCodec codec;
private TxMessageReceiveService service;
@BeforeEach
void setUp() {
codec = new TxMessageCodec(new com.fasterxml.jackson.databind.ObjectMapper());
service = new TxMessageReceiveService(codec, txRepository);
}
@Test
void receive_validApprovalMessage_returnsSuccess() {
// Given
byte[] raw = buildRawMessage("REQ", "M001", "T001", "10000");
when(txRepository.isMerchantActive("M001")).thenReturn(true);
when(txRepository.isTerminalActive("M001", "T001")).thenReturn(true);
// When
TxResult result = service.receive(raw);
// Then
assertTrue(result.isSuccess());
assertEquals("0000", result.getCode());
verify(txRepository).saveTransaction(any(TxMessage.class));
}
@Test
void receive_missingMerchantId_returnsError() {
// Given
byte[] raw = buildRawMessage("REQ", "", "T001", "10000");
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("1001", result.getCode());
}
@Test
void receive_missingTerminalId_returnsError() {
// Given
byte[] raw = buildRawMessage("REQ", "M001", "", "10000");
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("1002", result.getCode());
}
@Test
void receive_invalidAmount_returnsError() {
// Given
byte[] raw = buildRawMessage("REQ", "M001", "T001", "invalid");
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("1004", result.getCode());
}
@Test
void receive_inactiveMerchant_returnsError() {
// Given
byte[] raw = buildRawMessage("REQ", "M001", "T001", "10000");
when(txRepository.isMerchantActive("M001")).thenReturn(false);
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("2001", result.getCode());
}
@Test
void receive_inactiveTerminal_returnsError() {
// Given
byte[] raw = buildRawMessage("REQ", "M001", "T001", "10000");
when(txRepository.isMerchantActive("M001")).thenReturn(true);
when(txRepository.isTerminalActive("M001", "T001")).thenReturn(false);
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("2002", result.getCode());
}
@Test
void receive_cancelMessage_callsCancellation() {
// Given
byte[] raw = buildRawMessage("CANCEL", "M001", "T001", "10000");
when(txRepository.isMerchantActive("M001")).thenReturn(true);
when(txRepository.isTerminalActive("M001", "T001")).thenReturn(true);
when(txRepository.transactionExists(anyString())).thenReturn(true);
when(txRepository.getTransactionStatus(anyString())).thenReturn("APPROVED");
// When
TxResult result = service.receive(raw);
// Then
assertTrue(result.isSuccess());
verify(txRepository).updateTransactionStatus(anyString(), eq("CANCELLED"));
}
@Test
void receive_cancelNonExistentTransaction_returnsError() {
// Given
byte[] raw = buildRawMessage("CANCEL", "M001", "T001", "10000");
when(txRepository.isMerchantActive("M001")).thenReturn(true);
when(txRepository.isTerminalActive("M001", "T001")).thenReturn(true);
when(txRepository.transactionExists(anyString())).thenReturn(false);
// When
TxResult result = service.receive(raw);
// Then
assertFalse(result.isSuccess());
assertEquals("3001", result.getCode());
}
private byte[] buildRawMessage(String status, String merchantId, String terminalId, String amount) {
String body = String.format(
"{\"STATUS\":\"%s\",\"MERCHANTID\":\"%s\",\"TERMINALID\":\"%s\",\"AMOUNT\":\"%s\"}",
status, merchantId, terminalId, amount);
String header = String.format("%-20s", "TXMSG");
return (header + body).getBytes(StandardCharsets.ISO_8859_1);
}
}