| 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 2.2 3.3 4.4 5.5 |
|
| 49 |
1.1 |
|
| 52 |
1.1 |
|
| 53 |
1.1 2.2 |
|
| 61 |
1.1 |
|
| 64 |
1.1 2.2 |
|
| 76 |
1.1 |
|
| 81 |
1.1 |
|
| 92 |
1.1 2.2 3.3 |
|
| 95 |
1.1 2.2 3.3 |
|
| 96 |
1.1 |
|
| 97 |
1.1 |
|
| 98 |
1.1 |
|
| 101 |
1.1 |
|
| 102 |
1.1 |
|
| 107 |
1.1 |