StripePaymentGateway.java

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

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

Active mutators

Tests examined


Report generated by PIT 1.30.0 support