Skip to content
This repository was archived by the owner on Jul 6, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ public enum PaymentStatus {
REFUNDED,
NOT_FOUND,
TERMINATED,
EXPIRED
CAPTURED, EXPIRED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.zenfulcode.commercify.commercify.api.requests;

public record CapturePaymentRequest(double captureAmount, boolean isPartialCapture) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.zenfulcode.commercify.commercify.controller;

import com.zenfulcode.commercify.commercify.PaymentStatus;
import com.zenfulcode.commercify.commercify.service.PaymentService;
import com.zenfulcode.commercify.commercify.api.requests.CapturePaymentRequest;
import com.zenfulcode.commercify.commercify.integration.mobilepay.MobilePayService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
Expand All @@ -14,15 +15,15 @@
@AllArgsConstructor
@Slf4j
public class PaymentController {
private final PaymentService paymentService;
private final MobilePayService mobilePayService;

@PostMapping("/{orderId}/status")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> updatePaymentStatus(
@PathVariable Long orderId,
@RequestParam PaymentStatus status) {
try {
paymentService.handlePaymentStatusUpdate(orderId, status);
mobilePayService.handlePaymentStatusUpdate(orderId, status);
return ResponseEntity.ok("Payment status updated successfully");
} catch (Exception e) {
log.error("Error updating payment status", e);
Expand All @@ -32,7 +33,19 @@ public ResponseEntity<String> updatePaymentStatus(

@GetMapping("/{orderId}/status")
public ResponseEntity<PaymentStatus> getPaymentStatus(@PathVariable Long orderId) {
PaymentStatus status = paymentService.getPaymentStatus(orderId);
PaymentStatus status = mobilePayService.getPaymentStatus(orderId);
return ResponseEntity.ok(status);
}

@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/{paymentId}/capture")
public ResponseEntity<String> capturePayment(@PathVariable Long paymentId, @RequestBody CapturePaymentRequest request) {
try {
mobilePayService.capturePayment(paymentId, request.captureAmount(), request.isPartialCapture());
return ResponseEntity.ok("Payment captured successfully");
} catch (Exception e) {
log.error("Error capturing payment", e);
return ResponseEntity.badRequest().body("Error capturing payment");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public class OrderDTO {
private Instant createdAt;
private Instant updatedAt;

public OrderDTO() {

}

public double getTotal() {
return subTotal + shippingCost;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ public class OrderDetailsDTO {
private CustomerDetailsDTO customerDetails;
private AddressDTO shippingAddress;
private AddressDTO billingAddress;

public OrderDetailsDTO() {

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public OrderStateFlow() {
// Payment received -> Processing or Cancelled
validTransitions.put(OrderStatus.PAID, Set.of(
OrderStatus.SHIPPED,
OrderStatus.CANCELLED
OrderStatus.CANCELLED,
OrderStatus.COMPLETED
));

// Shipped -> Completed or Returned
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public ResponseEntity<String> handleCallback(
try {
// First authenticate the request with the raw string payload
mobilePayService.authenticateRequest(date, contentSha256, authorization, body, request);
log.info("MP Webhook authenticated");
log.info("Mobilepay Webhook authenticated");

// Convert the string payload to WebhookPayload object
ObjectMapper objectMapper = new ObjectMapper();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@
import com.zenfulcode.commercify.commercify.PaymentStatus;
import com.zenfulcode.commercify.commercify.api.requests.PaymentRequest;
import com.zenfulcode.commercify.commercify.api.requests.WebhookPayload;
import com.zenfulcode.commercify.commercify.api.requests.products.PriceRequest;
import com.zenfulcode.commercify.commercify.api.responses.PaymentResponse;
import com.zenfulcode.commercify.commercify.entity.OrderEntity;
import com.zenfulcode.commercify.commercify.dto.OrderDTO;
import com.zenfulcode.commercify.commercify.dto.OrderDetailsDTO;
import com.zenfulcode.commercify.commercify.entity.PaymentEntity;
import com.zenfulcode.commercify.commercify.entity.WebhookConfigEntity;
import com.zenfulcode.commercify.commercify.exception.OrderNotFoundException;
import com.zenfulcode.commercify.commercify.exception.PaymentProcessingException;
import com.zenfulcode.commercify.commercify.integration.WebhookRegistrationResponse;
import com.zenfulcode.commercify.commercify.repository.OrderRepository;
import com.zenfulcode.commercify.commercify.repository.PaymentRepository;
import com.zenfulcode.commercify.commercify.repository.WebhookConfigRepository;
import com.zenfulcode.commercify.commercify.service.PaymentService;
import com.zenfulcode.commercify.commercify.service.email.EmailService;
import com.zenfulcode.commercify.commercify.service.order.OrderService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
Expand All @@ -35,15 +37,15 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

@Service
@RequiredArgsConstructor
@Slf4j
public class MobilePayService {
private final PaymentService paymentService;
public class MobilePayService extends PaymentService {
private final MobilePayTokenService tokenService;

private final OrderRepository orderRepository;
private final OrderService orderService;
private final PaymentRepository paymentRepository;

private final RestTemplate restTemplate;
Expand All @@ -61,24 +63,59 @@ public class MobilePayService {
@Value("${mobilepay.api-url}")
private String apiUrl;

@Value("${commercify.host}")
private String host;

private static final String PROVIDER_NAME = "MOBILEPAY";

public MobilePayService(PaymentRepository paymentRepository, EmailService emailService, OrderService orderService, MobilePayTokenService tokenService, OrderService orderService1, PaymentRepository paymentRepository1, RestTemplate restTemplate, WebhookConfigRepository webhookConfigRepository) {
super(paymentRepository, emailService, orderService);
this.tokenService = tokenService;
this.orderService = orderService1;
this.paymentRepository = paymentRepository1;
this.restTemplate = restTemplate;
this.webhookConfigRepository = webhookConfigRepository;
}

@Override
public void capturePayment(Long paymentId, double captureAmount, boolean isPartialCapture) {
PaymentEntity payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new RuntimeException("Payment not found: " + paymentId));

if (payment.getStatus() != PaymentStatus.PAID) {
throw new RuntimeException("Payment cannot captured");
}

OrderDetailsDTO order = orderService.getOrderById(payment.getOrderId());

double capturingAmount = isPartialCapture ? captureAmount : payment.getTotalAmount();

PriceRequest priceRequest = new PriceRequest(order.getOrder().getCurrency(), capturingAmount);

// Capture payment
if (payment.getMobilePayReference() != null) {
capturePayment(payment.getMobilePayReference(), priceRequest);
}

// Update payment status
payment.setStatus(PaymentStatus.PAID);
paymentRepository.save(payment);
}

@Transactional
public PaymentResponse initiatePayment(PaymentRequest request) {
try {
OrderEntity order = orderRepository.findById(request.orderId())
.orElseThrow(() -> new OrderNotFoundException(request.orderId()));

OrderDetailsDTO orderDetails = orderService.getOrderById(request.orderId());
// Create MobilePay payment request
Map<String, Object> paymentRequest = createMobilePayRequest(order, request);
Map<String, Object> paymentRequest = createMobilePayRequest(orderDetails.getOrder(), request);

// Call MobilePay API
MobilePayCheckoutResponse mobilePayCheckoutResponse = createMobilePayPayment(paymentRequest);

// Create and save payment entity
PaymentEntity payment = PaymentEntity.builder()
.orderId(order.getId())
.totalAmount(order.getTotal())
.orderId(orderDetails.getOrder().getId())
.totalAmount(orderDetails.getOrder().getTotal())
.paymentProvider(PaymentProvider.MOBILEPAY)
.status(PaymentStatus.PENDING)
.paymentMethod(request.paymentMethod()) // 'WALLET' or 'CARD'
Expand Down Expand Up @@ -106,7 +143,7 @@ public void handlePaymentCallback(WebhookPayload payload) {
PaymentStatus newStatus = mapMobilePayStatus(payload.name());

// Update payment status and trigger confirmation email if needed
paymentService.handlePaymentStatusUpdate(payment.getOrderId(), newStatus);
handlePaymentStatusUpdate(payment.getOrderId(), newStatus);
}

private HttpHeaders mobilePayRequestHeaders() {
Expand Down Expand Up @@ -151,7 +188,7 @@ private MobilePayCheckoutResponse createMobilePayPayment(Map<String, Object> req
}
}

public Map<String, Object> createMobilePayRequest(OrderEntity order, PaymentRequest request) {
public Map<String, Object> createMobilePayRequest(OrderDTO order, PaymentRequest request) {
validationPaymentRequest(request);

Map<String, Object> paymentRequest = new HashMap<>();
Expand All @@ -174,7 +211,7 @@ public Map<String, Object> createMobilePayRequest(OrderEntity order, PaymentRequ
paymentRequest.put("customer", customer);

// Other fields
String reference = String.join("-", merchantId, systemName, order.getId().toString(), value);
String reference = String.join("-", merchantId, systemName, String.valueOf(order.getId()), value);
paymentRequest.put("reference", reference);
paymentRequest.put("returnUrl", request.returnUrl() + "?orderId=" + order.getId());
paymentRequest.put("userFlow", "WEB_REDIRECT");
Expand Down Expand Up @@ -211,9 +248,11 @@ private PaymentStatus mapMobilePayStatus(String status) {
return switch (status.toUpperCase()) {
case "CREATED" -> PaymentStatus.PENDING;
case "AUTHORIZED" -> PaymentStatus.PAID;
case "ABORTED" -> PaymentStatus.CANCELLED;
case "ABORTED", "CANCELLED" -> PaymentStatus.CANCELLED;
case "EXPIRED" -> PaymentStatus.EXPIRED;
case "TERMINATED" -> PaymentStatus.TERMINATED;
case "CAPTURED" -> PaymentStatus.CAPTURED;
case "REFUNDED" -> PaymentStatus.REFUNDED;
default -> throw new PaymentProcessingException("Unknown MobilePay status: " + status, null);
};
}
Expand Down Expand Up @@ -254,6 +293,8 @@ public void registerWebhooks(String callbackUrl) {
config.setWebhookUrl(callbackUrl);
config.setWebhookSecret(response.getBody().secret());
webhookConfigRepository.save(config);

log.info("Webhook updated successfully");
},
() -> {
WebhookConfigEntity newConfig = WebhookConfigEntity.builder()
Expand All @@ -262,6 +303,8 @@ public void registerWebhooks(String callbackUrl) {
.webhookSecret(response.getBody().secret())
.build();
webhookConfigRepository.save(newConfig);

log.info("Webhook registered successfully");
}
);

Expand All @@ -271,10 +314,11 @@ public void registerWebhooks(String callbackUrl) {
}
}


@Transactional(readOnly = true)
public void authenticateRequest(String date, String contentSha256, String authorization, String payload, HttpServletRequest request) {
try {
// Verify content
log.info("Verifying content");
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8));
String encodedHash = Base64.getEncoder().encodeToString(hash);
Expand All @@ -283,26 +327,34 @@ public void authenticateRequest(String date, String contentSha256, String author
throw new SecurityException("Hash mismatch");
}

URI uri = new URI(request.getRequestURL().toString());
String path = uri.getPath() + (uri.getQuery() != null ? "?" + uri.getQuery() : "");
log.info("Content verified");

// Verify signature
log.info("Verifying signature");
String path = request.getRequestURI();
URI uri = new URI(host + path);

String expectedSignedString = String.format("POST\n%s\n%s;%s;%s", path, date, uri.getHost(), encodedHash);

Mac hmacSha256 = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(getWebhookSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");

CompletableFuture<byte[]> secretByteArray = getWebhookSecret().thenApply(s -> s.getBytes(StandardCharsets.UTF_8));

SecretKeySpec secretKey = new SecretKeySpec(secretByteArray.get(), "HmacSHA256");
hmacSha256.init(secretKey);

byte[] hmacSha256Bytes = hmacSha256.doFinal(expectedSignedString.getBytes(StandardCharsets.UTF_8));
String expectedSignature = Base64.getEncoder().encodeToString(hmacSha256Bytes);
String expectedAuthorization = "HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=" + expectedSignature;
String expectedAuthorization = String.format("HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=%s", expectedSignature);

if (!authorization.equals(expectedAuthorization)) {
throw new SecurityException("Signature mismatch");
}

log.info("Signature verified");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not found", e);
} catch (InvalidKeyException | URISyntaxException e) {
} catch (InvalidKeyException | URISyntaxException | ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
}
}
Expand Down Expand Up @@ -343,13 +395,50 @@ public Object getWebhooks() {
}
}

private String getWebhookSecret() {
return webhookConfigRepository.findByProvider(PROVIDER_NAME)
.map(WebhookConfigEntity::getWebhookSecret)
.orElseThrow(() -> new PaymentProcessingException("Webhook secret not found", null));
@Async
protected CompletableFuture<String> getWebhookSecret() {
try {
final String secret = webhookConfigRepository.findByProvider(PROVIDER_NAME)
.map(WebhookConfigEntity::getWebhookSecret)
.orElseThrow(() -> new PaymentProcessingException("Webhook secret not found", null));

return CompletableFuture.completedFuture(secret);
} catch (Exception e) {
log.error("Error getting webhook secret: {}", e.getMessage());
return CompletableFuture.failedFuture(e);
}
}

public void capturePayment(String mobilePayReference, PriceRequest captureAmount) {
paymentRepository.findByMobilePayReference(mobilePayReference)
.orElseThrow(() -> new PaymentProcessingException("Payment not found", null));

HttpHeaders headers = mobilePayRequestHeaders();

Map<String, Object> request = new HashMap<>();
request.put("modificationAmount", new MobilePayPrice(Math.round(captureAmount.amount() * 100), captureAmount.currency()));

HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);

try {
restTemplate.exchange(
apiUrl + "/epayment/v1/payments/" + mobilePayReference + "/capture",
HttpMethod.POST,
entity,
Object.class);
} catch (Exception e) {
log.error("Error capturing MobilePay payment: {}", e.getMessage());
throw new PaymentProcessingException("Failed to capture MobilePay payment", e);
}
}
}

record MobilePayPrice(
long value,
String currency
) {
}

record MobilePayCheckoutResponse(
String redirectUrl,
String reference
Expand Down
Loading
Loading