PaymentService.java

1
package com.rbctcsworld.ecommerce.payment;
2
3
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
4
import com.rbctcsworld.ecommerce.common.exception.PaymentDeclinedException;
5
import com.rbctcsworld.ecommerce.common.exception.PaymentUnavailableException;
6
import com.rbctcsworld.ecommerce.payment.PaymentGateway.GatewayResult;
7
import com.rbctcsworld.ecommerce.payment.PaymentDtos.PayRequest;
8
import org.springframework.stereotype.Service;
9
import org.springframework.transaction.annotation.Propagation;
10
import org.springframework.transaction.annotation.Transactional;
11
12
import java.math.BigDecimal;
13
import java.time.Clock;
14
import java.time.YearMonth;
15
import java.util.List;
16
17
/**
18
 * Charges and refunds through the gateway and records every attempt.
19
 * Runs inside the caller's (OrderService) transaction.
20
 *
21
 * noRollbackFor: a declined card throws PaymentDeclinedException (-> HTTP 402), but the FAILED
22
 * transaction row must still be committed for audit/fraud analysis. Without this setting the
23
 * exception would mark the whole transaction rollback-only and the failed attempt would vanish.
24
 */
25
@Service
26
@Transactional(propagation = Propagation.MANDATORY, noRollbackFor = PaymentDeclinedException.class)
27
public class PaymentService {
28
29
    private final PaymentGateway gateway;
30
    private final PaymentTransactionRepository transactions;
31
    private final Clock clock;
32
33
    public PaymentService(PaymentGateway gateway, PaymentTransactionRepository transactions, Clock clock) {
34
        this.gateway = gateway;
35
        this.transactions = transactions;
36
        this.clock = clock;
37
    }
38
39
    /**
40
     * 400 for malformed/expired card (nothing recorded - the card never reached the gateway),
41
     * 402 for a declined card (FAILED transaction IS recorded), 503 when the provider cannot be reached
42
     * (FAILED "Provider unavailable" recorded), otherwise a SUCCEEDED transaction with the provider's id.
43
     */
44
    public PaymentTransaction charge(Long orderId, BigDecimal amount, PayRequest card) {
45
        String number = card.cardNumber().replace(" ", "");
46 5 1. charge : changed conditional boundary → KILLED
2. charge : changed conditional boundary → KILLED
3. charge : removed conditional - replaced equality check with false → KILLED
4. charge : removed conditional - replaced comparison check with false → KILLED
5. charge : removed conditional - replaced comparison check with false → KILLED
        if (number.length() < 13 || number.length() > 19 || !MockPaymentGateway.luhnValid(number)) {
47
            throw new BusinessRuleException("Invalid card number");
48
        }
49 1 1. charge : removed conditional - replaced equality check with false → KILLED
        if (YearMonth.of(card.expiryYear(), card.expiryMonth()).isBefore(YearMonth.now(clock))) {
50
            throw new BusinessRuleException("Card has expired");
51
        }
52 1 1. charge : Replaced integer subtraction with addition → KILLED
        String last4 = number.substring(number.length() - 4);
53 2 1. lambda$charge$0 : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$charge$0 → KILLED
2. charge : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::charge → KILLED
        return settle(orderId, amount, last4, () -> gateway.charge(orderId, number, amount));
54
    }
55
56
    /**
57
     * Charges a saved card by its provider token. An expired card is 400 and nothing is recorded; everything else
58
     * behaves like {@link #charge}.
59
     */
60
    public PaymentTransaction chargeSaved(Long orderId, BigDecimal amount, SavedPaymentMethod method) {
61 1 1. chargeSaved : removed conditional - replaced equality check with false → KILLED
        if (method.expiredAt(YearMonth.now(clock))) {
62
            throw new BusinessRuleException("Card has expired");
63
        }
64 2 1. lambda$chargeSaved$1 : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$chargeSaved$1 → KILLED
2. chargeSaved : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::chargeSaved → KILLED
        return settle(orderId, amount, method.getLast4(), () -> gateway.chargeToken(orderId, method.getToken(), amount));
65
    }
66
67
    private PaymentTransaction settle(Long orderId, BigDecimal amount, String last4, java.util.function.Supplier<GatewayResult> call) {
68
        GatewayResult result;
69
        try {
70
            result = call.get();
71
        } catch (PaymentUnavailableException e) {
72
            transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE, PaymentTransaction.FAILED,
73
                    amount, last4, "Provider unavailable", gateway.name(), null));
74
            throw e;
75
        }
76 1 1. settle : removed conditional - replaced equality check with false → KILLED
        if (!result.approved()) {
77
            transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE, PaymentTransaction.FAILED,
78
                    amount, last4, result.reason(), gateway.name(), result.reference()));
79
            throw new PaymentDeclinedException("Payment declined: " + result.reason());
80
        }
81 1 1. settle : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::settle → KILLED
        return transactions.save(new PaymentTransaction(orderId, PaymentTransaction.CHARGE,
82
                PaymentTransaction.SUCCEEDED, amount, last4, null, gateway.name(), result.reference()));
83
    }
84
85
    /**
86
     * Refunds an amount of the order's successful charge (cancelled order: all of it; return: the refund
87
     * the return policy allows). A provider that cannot refund -> 503, and the caller's transaction rolls back.
88
     */
89
    public PaymentTransaction refund(Long orderId, BigDecimal amount) {
90
        List<PaymentTransaction> history = transactions.findByOrderIdOrderByIdAsc(orderId);
91
        PaymentTransaction charge = history.stream()
92 3 1. lambda$refund$2 : removed conditional - replaced equality check with false → KILLED
2. lambda$refund$2 : replaced boolean return with true for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$refund$2 → KILLED
3. lambda$refund$2 : removed conditional - replaced equality check with false → KILLED
                .filter(t -> PaymentTransaction.CHARGE.equals(t.getType()) && PaymentTransaction.SUCCEEDED.equals(t.getStatus()))
93
                .findFirst().orElse(null);
94
        int refundNumber = 1 + (int) history.stream()
95 3 1. lambda$refund$3 : replaced boolean return with true for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$refund$3 → KILLED
2. lambda$refund$3 : removed conditional - replaced equality check with false → KILLED
3. lambda$refund$3 : removed conditional - replaced equality check with false → KILLED
                .filter(t -> PaymentTransaction.REFUND.equals(t.getType()) && PaymentTransaction.SUCCEEDED.equals(t.getStatus()))
96 1 1. refund : Replaced integer addition with subtraction → KILLED
                .count();
97 1 1. refund : removed conditional - replaced equality check with false → KILLED
        GatewayResult result = gateway.refund(orderId, charge == null ? null : charge.getProviderReference(), amount, refundNumber);
98 1 1. refund : removed conditional - replaced equality check with false → KILLED
        if (!result.approved()) {
99
            throw new PaymentUnavailableException("Refund was not accepted: " + result.reason());
100
        }
101 1 1. refund : replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::refund → KILLED
        return transactions.save(new PaymentTransaction(orderId, PaymentTransaction.REFUND, PaymentTransaction.SUCCEEDED,
102 1 1. refund : removed conditional - replaced equality check with false → KILLED
                amount, charge == null ? null : charge.getCardLast4(), null, gateway.name(), result.reference()));
103
    }
104
105
    @Transactional(propagation = Propagation.SUPPORTS, readOnly = true)
106
    public List<PaymentTransaction> history(Long orderId) {
107 1 1. history : replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/payment/PaymentService::history → NO_COVERAGE
        return transactions.findByOrderIdOrderByIdAsc(orderId);
108
    }
109
}

Mutations

46

1.1
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[test-template:cardNumberLengthMustBe13To19Digits(int, java.lang.String, boolean)]/[test-template-invocation:#3]
changed conditional boundary → KILLED

2.2
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[test-template:cardNumberLengthMustBe13To19Digits(int, java.lang.String, boolean)]/[test-template-invocation:#2]
changed conditional boundary → KILLED

3.3
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[test-template:luhnOrLengthFailureIs400AndNothingRecorded(java.lang.String)]/[test-template-invocation:#2]
removed conditional - replaced equality check with false → KILLED

4.4
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:providerOutageIsRecordedAndReportedAs503()]
removed conditional - replaced comparison check with false → KILLED

5.5
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:providerOutageIsRecordedAndReportedAs503()]
removed conditional - replaced comparison check with false → KILLED

49

1.1
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:expiryBoundary()]
removed conditional - replaced equality check with false → KILLED

52

1.1
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:providerOutageIsRecordedAndReportedAs503()]
Replaced integer subtraction with addition → KILLED

53

1.1
Location : lambda$charge$0
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:declineKeepsTheProviderReferenceForSupport()]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$charge$0 → KILLED

2.2
Location : charge
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[test-template:cardNumberLengthMustBe13To19Digits(int, java.lang.String, boolean)]/[test-template-invocation:#2]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::charge → KILLED

61

1.1
Location : chargeSaved
Killed by : com.rbctcsworld.ecommerce.payment.SavedCardChargeTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.SavedCardChargeTest]/[method:anExpiredSavedCardNeverReachesTheGateway()]
removed conditional - replaced equality check with false → KILLED

64

1.1
Location : lambda$chargeSaved$1
Killed by : com.rbctcsworld.ecommerce.payment.SavedCardChargeTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.SavedCardChargeTest]/[method:anApprovedSavedCardIsRecordedWithItsLastFourDigits()]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$chargeSaved$1 → KILLED

2.2
Location : chargeSaved
Killed by : com.rbctcsworld.ecommerce.payment.SavedCardChargeTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.SavedCardChargeTest]/[method:anApprovedSavedCardIsRecordedWithItsLastFourDigits()]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::chargeSaved → KILLED

76

1.1
Location : settle
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:declineKeepsTheProviderReferenceForSupport()]
removed conditional - replaced equality check with false → KILLED

81

1.1
Location : settle
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[test-template:cardNumberLengthMustBe13To19Digits(int, java.lang.String, boolean)]/[test-template-invocation:#2]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::settle → KILLED

92

1.1
Location : lambda$refund$2
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refusedRefundRollsBackAs503()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : lambda$refund$2
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundUsesTheChargeReferenceAndCountsEarlierRefunds()]
replaced boolean return with true for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$refund$2 → KILLED

3.3
Location : lambda$refund$2
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refusedRefundRollsBackAs503()]
removed conditional - replaced equality check with false → KILLED

95

1.1
Location : lambda$refund$3
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refusedRefundRollsBackAs503()]
replaced boolean return with true for com/rbctcsworld/ecommerce/payment/PaymentService::lambda$refund$3 → KILLED

2.2
Location : lambda$refund$3
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundUsesTheChargeReferenceAndCountsEarlierRefunds()]
removed conditional - replaced equality check with false → KILLED

3.3
Location : lambda$refund$3
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundUsesTheChargeReferenceAndCountsEarlierRefunds()]
removed conditional - replaced equality check with false → KILLED

96

1.1
Location : refund
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundUsesTheChargeReferenceAndCountsEarlierRefunds()]
Replaced integer addition with subtraction → KILLED

97

1.1
Location : refund
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundWithoutAnyChargeStillAsksTheGatewayWithoutReference()]
removed conditional - replaced equality check with false → KILLED

98

1.1
Location : refund
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refusedRefundRollsBackAs503()]
removed conditional - replaced equality check with false → KILLED

101

1.1
Location : refund
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundWithoutAnyChargeStillAsksTheGatewayWithoutReference()]
replaced return value with null for com/rbctcsworld/ecommerce/payment/PaymentService::refund → KILLED

102

1.1
Location : refund
Killed by : com.rbctcsworld.ecommerce.payment.PaymentServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.PaymentServiceTest]/[method:refundWithoutAnyChargeStillAsksTheGatewayWithoutReference()]
removed conditional - replaced equality check with false → KILLED

107

1.1
Location : history
Killed by : none
replaced return value with Collections.emptyList for com/rbctcsworld/ecommerce/payment/PaymentService::history → NO_COVERAGE

Active mutators

Tests examined


Report generated by PIT 1.30.0 support