PaymentService.java

package com.rbctcsworld.ecommerce.payment;

import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.PaymentDeclinedException;
import com.rbctcsworld.ecommerce.common.exception.PaymentUnavailableException;
import com.rbctcsworld.ecommerce.payment.PaymentGateway.GatewayResult;
import com.rbctcsworld.ecommerce.payment.PaymentDtos.PayRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.YearMonth;
import java.util.List;

/**
 * Charges and refunds through the gateway and records every attempt.
 * Runs inside the caller's (OrderService) transaction.
 *
 * noRollbackFor: a declined card throws PaymentDeclinedException (-> HTTP 402), but the FAILED
 * transaction row must still be committed for audit/fraud analysis. Without this setting the
 * exception would mark the whole transaction rollback-only and the failed attempt would vanish.
 */
@Service
@Transactional(propagation = Propagation.MANDATORY, noRollbackFor = PaymentDeclinedException.class)
public class PaymentService {

    private final PaymentGateway gateway;
    private final PaymentTransactionRepository transactions;
    private final Clock clock;

    public PaymentService(PaymentGateway gateway, PaymentTransactionRepository transactions, Clock clock) {
        this.gateway = gateway;
        this.transactions = transactions;
        this.clock = clock;
    }

    /**
     * 400 for malformed/expired card (nothing recorded - the card never reached the gateway),
     * 402 for a declined card (FAILED transaction IS recorded), 503 when the provider cannot be reached
     * (FAILED "Provider unavailable" recorded), otherwise a SUCCEEDED transaction with the provider's id.
     */
    public PaymentTransaction charge(Long orderId, BigDecimal amount, PayRequest card) {
        String number = card.cardNumber().replace(" ", "");
        if (number.length() < 13 || number.length() > 19 || !MockPaymentGateway.luhnValid(number)) {
            throw new BusinessRuleException("Invalid card number");
        }
        if (YearMonth.of(card.expiryYear(), card.expiryMonth()).isBefore(YearMonth.now(clock))) {
            throw new BusinessRuleException("Card has expired");
        }
        String last4 = number.substring(number.length() - 4);
        return settle(orderId, amount, last4, () -> gateway.charge(orderId, number, amount));
    }

    /**
     * Charges a saved card by its provider token. An expired card is 400 and nothing is recorded; everything else
     * behaves like {@link #charge}.
     */
    public PaymentTransaction chargeSaved(Long orderId, BigDecimal amount, SavedPaymentMethod method) {
        if (method.expiredAt(YearMonth.now(clock))) {
            throw new BusinessRuleException("Card has expired");
        }
        return settle(orderId, amount, method.getLast4(), () -> gateway.chargeToken(orderId, method.getToken(), amount));
    }

    private PaymentTransaction settle(Long orderId, BigDecimal amount, String last4, java.util.function.Supplier<GatewayResult> call) {
        GatewayResult result;
        try {
            result = call.get();
        } catch (PaymentUnavailableException e) {
            transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE, PaymentTransaction.FAILED,
                    amount, last4, "Provider unavailable", gateway.name(), null));
            throw e;
        }
        if (!result.approved()) {
            transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE, PaymentTransaction.FAILED,
                    amount, last4, result.reason(), gateway.name(), result.reference()));
            throw new PaymentDeclinedException("Payment declined: " + result.reason());
        }
        return transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE,
                PaymentTransaction.SUCCEEDED, amount, last4, null, gateway.name(), result.reference()));
    }

    /**
     * Refunds an amount of the order's successful charge (cancelled order: all of it; return: the refund
     * the return policy allows). A provider that cannot refund -> 503, and the caller's transaction rolls back.
     */
    public PaymentTransaction refund(Long orderId, BigDecimal amount) {
        List<PaymentTransaction> history = transactions.findByOrderIdOrderByIdAsc(orderId);
        PaymentTransaction charge = history.stream()
                .filter(t -> PaymentTransaction.CHARGE.equals(t.getType()) && PaymentTransaction.SUCCEEDED.equals(t.getStatus()))
                .findFirst().orElse(null);
        int refundNumber = 1 + (int) history.stream()
                .filter(t -> PaymentTransaction.REFUND.equals(t.getType()) && PaymentTransaction.SUCCEEDED.equals(t.getStatus()))
                .count();
        GatewayResult result = gateway.refund(orderId, charge == null ? null : charge.getProviderReference(), amount, refundNumber);
        if (!result.approved()) {
            throw new PaymentUnavailableException("Refund was not accepted: " + result.reason());
        }
        return transactions.save(new PaymentTransaction(orderId, PaymentTransaction.REFUND, PaymentTransaction.SUCCEEDED,
                amount, charge == null ? null : charge.getCardLast4(), null, gateway.name(), result.reference()));
    }

    @Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
    public List<PaymentTransaction> history(Long orderId) {
        return transactions.findByOrderIdOrderByIdAsc(orderId);
    }
}