|
1
|
|
package com.rbctcsworld.ecommerce.payment; |
|
2
|
|
|
|
3
|
|
import com.rbctcsworld.ecommerce.common.exception.PaymentUnavailableException; |
|
4
|
|
import org.slf4j.Logger; |
|
5
|
|
import org.slf4j.LoggerFactory; |
|
6
|
|
|
|
7
|
|
import java.math.BigDecimal; |
|
8
|
|
import java.math.RoundingMode; |
|
9
|
|
import java.util.Map; |
|
10
|
|
|
|
11
|
|
/** |
|
12
|
|
* Payments through Stripe (PaymentIntents + Refunds), app.payment.provider=stripe. |
|
13
|
|
* |
|
14
|
|
* Card numbers never go to Stripe: in a real shop the browser hands the card to Stripe (Stripe Elements) |
|
15
|
|
* and the backend only sees a PaymentMethod id. This demo keeps its own card form, so the backend maps |
|
16
|
|
* Stripe's published test cards to Stripe's ready-made test PaymentMethods. Any other number is refused. |
|
17
|
|
* |
|
18
|
|
* Outcomes: |
|
19
|
|
* - status "succeeded" -> approved, the PaymentIntent id is kept for refunds |
|
20
|
|
* - card_error (402 from Stripe) -> declined with a short reason, same texts as the mock gateway |
|
21
|
|
* - anything else (network, 5xx, bad key, invalid request) -> PaymentUnavailableException (HTTP 503) |
|
22
|
|
*/ |
|
23
|
|
public class StripePaymentGateway implements PaymentGateway { |
|
24
|
|
|
|
25
|
|
private static final Logger log = LoggerFactory.getLogger(StripePaymentGateway.class); |
|
26
|
|
|
|
27
|
|
/** Stripe test card -> Stripe test PaymentMethod (https://docs.stripe.com/testing). */ |
|
28
|
|
static final Map<String, String> TEST_PAYMENT_METHODS = Map.of( |
|
29
|
|
"4242424242424242", "pm_card_visa", |
|
30
|
|
"5555555555554444", "pm_card_mastercard", |
|
31
|
|
MockPaymentGateway.DECLINED_CARD, "pm_card_chargeDeclined", |
|
32
|
|
MockPaymentGateway.INSUFFICIENT_FUNDS_CARD, "pm_card_chargeDeclinedInsufficientFunds", |
|
33
|
|
"4000000000000069", "pm_card_chargeDeclinedExpiredCard", |
|
34
|
|
"4000000000000127", "pm_card_chargeDeclinedIncorrectCvc"); |
|
35
|
|
|
|
36
|
|
static final String UNAVAILABLE = "Payment service is not available right now. Please try again in a moment."; |
|
37
|
|
|
|
38
|
|
private final StripeClient stripe; |
|
39
|
|
private final String currency; |
|
40
|
|
|
|
41
|
|
StripePaymentGateway(StripeClient stripe, String currency) { |
|
42
|
|
this.stripe = stripe; |
|
43
|
|
this.currency = currency; |
|
44
|
|
} |
|
45
|
|
|
|
46
|
|
@Override |
|
47
|
|
public GatewayResult charge(Long orderId, String cardNumber, BigDecimal amount) { |
|
48
|
|
String paymentMethod = TEST_PAYMENT_METHODS.get(cardNumber); |
|
49
|
1
1. charge : removed conditional - replaced equality check with false → KILLED
|
if (paymentMethod == null) { |
|
50
|
1
1. charge : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::charge → KILLED
|
return GatewayResult.decline("Use a Stripe test card, e.g. 4242 4242 4242 4242", null); |
|
51
|
|
} |
|
52
|
1
1. charge : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::charge → KILLED
|
return chargeToken(orderId, paymentMethod, amount); |
|
53
|
|
} |
|
54
|
|
|
|
55
|
|
/** In test mode the "token" of a card is its Stripe test PaymentMethod; live mode would attach a real one. */ |
|
56
|
|
@Override |
|
57
|
|
public String tokenize(String cardNumber) { |
|
58
|
1
1. tokenize : replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::tokenize → NO_COVERAGE
|
return TEST_PAYMENT_METHODS.get(cardNumber); |
|
59
|
|
} |
|
60
|
|
|
|
61
|
|
@Override |
|
62
|
|
public GatewayResult chargeToken(Long orderId, String paymentMethod, BigDecimal amount) { |
|
63
|
2
1. chargeToken : removed conditional - replaced equality check with false → SURVIVED
2. chargeToken : removed conditional - replaced equality check with false → KILLED
|
if (paymentMethod == null || !paymentMethod.startsWith("pm_")) { |
|
64
|
1
1. chargeToken : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → NO_COVERAGE
|
return GatewayResult.decline("Unknown saved card", null); |
|
65
|
|
} |
|
66
|
|
long cents = cents(amount); |
|
67
|
|
// same order + amount + card = same key: a retry after a timeout returns the first answer, no double charge |
|
68
|
|
String key = "order-" + orderId + "-charge-" + cents + "-" + paymentMethod; |
|
69
|
|
try { |
|
70
|
|
StripeClient.PaymentIntent intent = stripe.createPaymentIntent(cents, currency, paymentMethod, orderId, key); |
|
71
|
1
1. chargeToken : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → KILLED
|
return switch (intent.status()) { |
|
72
|
|
case "succeeded" -> GatewayResult.ok(intent.id()); |
|
73
|
|
case "requires_action" -> GatewayResult.decline("Card needs authentication (3-D Secure)", intent.id()); |
|
74
|
|
default -> GatewayResult.decline("Payment not completed (" + intent.status() + ")", intent.id()); |
|
75
|
|
}; |
|
76
|
|
} catch (StripeException e) { |
|
77
|
1
1. chargeToken : removed conditional - replaced equality check with false → KILLED
|
if (e.isCardError()) { |
|
78
|
1
1. chargeToken : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → KILLED
|
return GatewayResult.decline(reason(e), e.paymentIntentId()); |
|
79
|
|
} |
|
80
|
|
throw unavailable("charge", orderId, e); |
|
81
|
|
} |
|
82
|
|
} |
|
83
|
|
|
|
84
|
|
@Override |
|
85
|
|
public GatewayResult refund(Long orderId, String chargeReference, BigDecimal amount, int refundNumber) { |
|
86
|
2
1. refund : removed conditional - replaced equality check with false → KILLED
2. refund : removed conditional - replaced equality check with false → KILLED
|
if (chargeReference == null || !chargeReference.startsWith("pi_")) { |
|
87
|
|
throw new PaymentUnavailableException("This payment was not made through Stripe and cannot be refunded automatically."); |
|
88
|
|
} |
|
89
|
|
long cents = cents(amount); |
|
90
|
|
String key = "order-" + orderId + "-refund-" + refundNumber + "-" + cents; |
|
91
|
|
try { |
|
92
|
|
StripeClient.Refund refund = stripe.createRefund(chargeReference, cents, orderId, key); |
|
93
|
1
1. refund : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::refund → KILLED
|
return switch (refund.status()) { |
|
94
|
|
case "succeeded", "pending" -> GatewayResult.ok(refund.id()); // card refunds may settle later |
|
95
|
|
default -> GatewayResult.decline("Refund " + refund.status(), refund.id()); |
|
96
|
|
}; |
|
97
|
|
} catch (StripeException e) { |
|
98
|
|
throw unavailable("refund", orderId, e); |
|
99
|
|
} |
|
100
|
|
} |
|
101
|
|
|
|
102
|
|
@Override |
|
103
|
|
public String name() { |
|
104
|
1
1. name : replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::name → KILLED
|
return "stripe"; |
|
105
|
|
} |
|
106
|
|
|
|
107
|
|
/** Stripe wants whole cents: 24.50 -> 2450 (stripe-mock rejects "24.50"). */ |
|
108
|
|
static long cents(BigDecimal amount) { |
|
109
|
1
1. cents : replaced long return with 0 for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::cents → KILLED
|
return amount.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact(); |
|
110
|
|
} |
|
111
|
|
|
|
112
|
|
/** Short, customer-safe reasons - the same texts as the mock gateway for the same test cards. */ |
|
113
|
|
static String reason(StripeException e) { |
|
114
|
1
1. reason : removed conditional - replaced equality check with false → KILLED
|
String code = e.declineCode() != null ? e.declineCode() : e.code(); |
|
115
|
2
1. reason : replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::reason → KILLED
2. reason : removed conditional - replaced equality check with false → KILLED
|
if (code == null) return "Card declined"; |
|
116
|
1
1. reason : replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::reason → KILLED
|
return switch (code) { |
|
117
|
|
case "insufficient_funds" -> "Insufficient funds"; |
|
118
|
|
case "expired_card" -> "Card has expired"; |
|
119
|
|
case "incorrect_cvc" -> "Incorrect CVC"; |
|
120
|
|
default -> "Card declined"; |
|
121
|
|
}; |
|
122
|
|
} |
|
123
|
|
|
|
124
|
|
private static PaymentUnavailableException unavailable(String action, Long orderId, StripeException e) { |
|
125
|
|
// the message may help support; it never contains card data or the API key |
|
126
|
|
log.error("Stripe {} for order {} failed: HTTP {} {} {}", action, orderId, e.status(), e.type(), e.getMessage()); |
|
127
|
1
1. unavailable : replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::unavailable → KILLED
|
return new PaymentUnavailableException(UNAVAILABLE); |
|
128
|
|
} |
|
129
|
|
} |
| | Mutations |
| 49 |
|
1.1 Location : charge Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:unknownCardNeverReachesStripe()] removed conditional - replaced equality check with false → KILLED
|
| 50 |
|
1.1 Location : charge Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:unknownCardNeverReachesStripe()] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::charge → KILLED
|
| 52 |
|
1.1 Location : charge Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:approvedChargeKeepsThePaymentIntentId()] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::charge → KILLED
|
| 58 |
|
1.1 Location : tokenize Killed by : none replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::tokenize → NO_COVERAGE
|
| 63 |
|
1.1 Location : chargeToken Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:sameOrderAmountAndCardGiveTheSameIdempotencyKeyAnotherCardADifferentOne()] removed conditional - replaced equality check with false → KILLED
2.2 Location : chargeToken Killed by : none removed conditional - replaced equality check with false → SURVIVED
Covering tests
Covered by tests:
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:sameOrderAmountAndCardGiveTheSameIdempotencyKeyAnotherCardADifferentOne()]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#6]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:threeDSecureIsADeclineWithAClearReason()]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:everythingThatIsNotAboutTheCardIs503(int, java.lang.String)]/[test-template-invocation:#3]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:everythingThatIsNotAboutTheCardIs503(int, java.lang.String)]/[test-template-invocation:#4]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:everythingThatIsNotAboutTheCardIs503(int, java.lang.String)]/[test-template-invocation:#2]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:everythingThatIsNotAboutTheCardIs503(int, java.lang.String)]/[test-template-invocation:#5]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:approvedChargeKeepsThePaymentIntentId()]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:anyOtherStatusIsNotAPayment()]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#2]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#1]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#5]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#4]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#3]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:testCardsMapToStripeTestPaymentMethods()]
- com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:everythingThatIsNotAboutTheCardIs503(int, java.lang.String)]/[test-template-invocation:#1]
|
| 64 |
|
1.1 Location : chargeToken Killed by : none replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → NO_COVERAGE
|
| 71 |
|
1.1 Location : chargeToken Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:approvedChargeKeepsThePaymentIntentId()] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → KILLED
|
| 77 |
|
1.1 Location : chargeToken Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#6] removed conditional - replaced equality check with false → KILLED
|
| 78 |
|
1.1 Location : chargeToken Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#6] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::chargeToken → KILLED
|
| 86 |
|
1.1 Location : refund Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:refundSucceedsOrIsPending(java.lang.String)]/[test-template-invocation:#1] removed conditional - replaced equality check with false → KILLED
2.2 Location : refund Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:paymentsNotMadeThroughStripeCannotBeRefundedThere(java.lang.String)]/[test-template-invocation:#2] removed conditional - replaced equality check with false → KILLED
|
| 93 |
|
1.1 Location : refund Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:refundSucceedsOrIsPending(java.lang.String)]/[test-template-invocation:#1] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::refund → KILLED
|
| 104 |
|
1.1 Location : name Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:nameIsStripe()] replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::name → KILLED
|
| 109 |
|
1.1 Location : cents Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:amountsGoToStripeInCents()] replaced long return with 0 for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::cents → KILLED
|
| 114 |
|
1.1 Location : reason Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#1] removed conditional - replaced equality check with false → KILLED
|
| 115 |
|
1.1 Location : reason Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#6] replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::reason → KILLED
2.2 Location : reason Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#6] removed conditional - replaced equality check with false → KILLED
|
| 116 |
|
1.1 Location : reason Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[test-template:cardErrorsBecomeShortCustomerSafeReasons(java.lang.String, java.lang.String, java.lang.String)]/[test-template-invocation:#2] replaced return value with "" for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::reason → KILLED
|
| 127 |
|
1.1 Location : unavailable Killed by : com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.payment.StripePaymentGatewayTest]/[method:refundErrorIs503()] replaced return value with null for com/rbctcsworld/ecommerce/payment/StripePaymentGateway::unavailable → KILLED
|