StripePaymentGateway.java
package com.rbctcsworld.ecommerce.payment;
import com.rbctcsworld.ecommerce.common.exception.PaymentUnavailableException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Map;
/**
* Payments through Stripe (PaymentIntents + Refunds), app.payment.provider=stripe.
*
* Card numbers never go to Stripe: in a real shop the browser hands the card to Stripe (Stripe Elements)
* and the backend only sees a PaymentMethod id. This demo keeps its own card form, so the backend maps
* Stripe's published test cards to Stripe's ready-made test PaymentMethods. Any other number is refused.
*
* Outcomes:
* - status "succeeded" -> approved, the PaymentIntent id is kept for refunds
* - card_error (402 from Stripe) -> declined with a short reason, same texts as the mock gateway
* - anything else (network, 5xx, bad key, invalid request) -> PaymentUnavailableException (HTTP 503)
*/
public class StripePaymentGateway implements PaymentGateway {
private static final Logger log = LoggerFactory.getLogger(StripePaymentGateway.class);
/** Stripe test card -> Stripe test PaymentMethod (https://docs.stripe.com/testing). */
static final Map<String, String> TEST_PAYMENT_METHODS = Map.of(
"4242424242424242", "pm_card_visa",
"5555555555554444", "pm_card_mastercard",
MockPaymentGateway.DECLINED_CARD, "pm_card_chargeDeclined",
MockPaymentGateway.INSUFFICIENT_FUNDS_CARD, "pm_card_chargeDeclinedInsufficientFunds",
"4000000000000069", "pm_card_chargeDeclinedExpiredCard",
"4000000000000127", "pm_card_chargeDeclinedIncorrectCvc");
static final String UNAVAILABLE = "Payment service is not available right now. Please try again in a moment.";
private final StripeClient stripe;
private final String currency;
StripePaymentGateway(StripeClient stripe, String currency) {
this.stripe = stripe;
this.currency = currency;
}
@Override
public GatewayResult charge(Long orderId, String cardNumber, BigDecimal amount) {
String paymentMethod = TEST_PAYMENT_METHODS.get(cardNumber);
if (paymentMethod == null) {
return GatewayResult.decline("Use a Stripe test card, e.g. 4242 4242 4242 4242", null);
}
return chargeToken(orderId, paymentMethod, amount);
}
/** In test mode the "token" of a card is its Stripe test PaymentMethod; live mode would attach a real one. */
@Override
public String tokenize(String cardNumber) {
return TEST_PAYMENT_METHODS.get(cardNumber);
}
@Override
public GatewayResult chargeToken(Long orderId, String paymentMethod, BigDecimal amount) {
if (paymentMethod == null || !paymentMethod.startsWith("pm_")) {
return GatewayResult.decline("Unknown saved card", null);
}
long cents = cents(amount);
// same order + amount + card = same key: a retry after a timeout returns the first answer, no double charge
String key = "order-" + orderId + "-charge-" + cents + "-" + paymentMethod;
try {
StripeClient.PaymentIntent intent = stripe.createPaymentIntent(cents, currency, paymentMethod, orderId, key);
return switch (intent.status()) {
case "succeeded" -> GatewayResult.ok(intent.id());
case "requires_action" -> GatewayResult.decline("Card needs authentication (3-D Secure)", intent.id());
default -> GatewayResult.decline("Payment not completed (" + intent.status() + ")", intent.id());
};
} catch (StripeException e) {
if (e.isCardError()) {
return GatewayResult.decline(reason(e), e.paymentIntentId());
}
throw unavailable("charge", orderId, e);
}
}
@Override
public GatewayResult refund(Long orderId, String chargeReference, BigDecimal amount, int refundNumber) {
if (chargeReference == null || !chargeReference.startsWith("pi_")) {
throw new PaymentUnavailableException("This payment was not made through Stripe and cannot be refunded automatically.");
}
long cents = cents(amount);
String key = "order-" + orderId + "-refund-" + refundNumber + "-" + cents;
try {
StripeClient.Refund refund = stripe.createRefund(chargeReference, cents, orderId, key);
return switch (refund.status()) {
case "succeeded", "pending" -> GatewayResult.ok(refund.id()); // card refunds may settle later
default -> GatewayResult.decline("Refund " + refund.status(), refund.id());
};
} catch (StripeException e) {
throw unavailable("refund", orderId, e);
}
}
@Override
public String name() {
return "stripe";
}
/** Stripe wants whole cents: 24.50 -> 2450 (stripe-mock rejects "24.50"). */
static long cents(BigDecimal amount) {
return amount.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact();
}
/** Short, customer-safe reasons - the same texts as the mock gateway for the same test cards. */
static String reason(StripeException e) {
String code = e.declineCode() != null ? e.declineCode() : e.code();
if (code == null) return "Card declined";
return switch (code) {
case "insufficient_funds" -> "Insufficient funds";
case "expired_card" -> "Card has expired";
case "incorrect_cvc" -> "Incorrect CVC";
default -> "Card declined";
};
}
private static PaymentUnavailableException unavailable(String action, Long orderId, StripeException e) {
// the message may help support; it never contains card data or the API key
log.error("Stripe {} for order {} failed: HTTP {} {} {}", action, orderId, e.status(), e.type(), e.getMessage());
return new PaymentUnavailableException(UNAVAILABLE);
}
}