PaymentMethodService.java

package com.rbctcsworld.ecommerce.payment;

import com.rbctcsworld.ecommerce.auth.AppUser;
import com.rbctcsworld.ecommerce.auth.UserRepository;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.payment.PaymentDtos.AddCardRequest;
import com.rbctcsworld.ecommerce.payment.PaymentDtos.MethodResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;

/**
 * The customer's wallet of saved cards. Rules:
 *  - what is kept: brand, last 4, expiry and the provider's token - never the number or the CVV;
 *  - at most {@value #MAX} cards (409), the same card twice is refused (409);
 *  - the first card is the default; makeDefault moves it; deleting the default makes the newest remaining card default;
 *  - somebody else's card does not exist for you (404);
 *  - a malformed, Luhn-invalid or expired card is 400 and a card the provider cannot use is 400.
 */
@Service
@Transactional
public class PaymentMethodService {

    public static final int MAX = 5;

    private final SavedPaymentMethodRepository repo;
    private final UserRepository users;
    private final PaymentGateway gateway;
    private final Clock clock;

    public PaymentMethodService(SavedPaymentMethodRepository repo, UserRepository users, PaymentGateway gateway, Clock clock) {
        this.repo = repo;
        this.users = users;
        this.gateway = gateway;
        this.clock = clock;
    }

    @Transactional(readOnly = true)
    public List<MethodResponse> list(String email) {
        YearMonth now = YearMonth.now(clock);
        return mine(user(email).getId()).stream().map(m -> MethodResponse.from(m, now)).toList();
    }

    public MethodResponse add(String email, AddCardRequest r) {
        Long userId = user(email).getId();
        return MethodResponse.from(save(userId, r.cardNumber(), r.expiryMonth(), r.expiryYear(), r.makeDefault()), YearMonth.now(clock));
    }

    /** "Save this card" after a payment went through: never fails the payment, a full wallet or a twin is simply skipped. */
    public void saveAfterPayment(String email, String cardNumber, int month, int year) {
        try {
            save(user(email).getId(), cardNumber, month, year, false);
        } catch (ConflictException | BusinessRuleException ignored) {
            // wallet full, card already saved, or not storable at the provider: the payment itself succeeded
        }
    }

    private SavedPaymentMethod save(Long userId, String rawNumber, int month, int year, boolean makeDefault) {
        String number = rawNumber.replace(" ", "");
        if (number.length() < 13 || number.length() > 19 || !MockPaymentGateway.luhnValid(number)) {
            throw new BusinessRuleException("Invalid card number");
        }
        if (YearMonth.of(year, month).isBefore(YearMonth.now(clock))) {
            throw new BusinessRuleException("Card has expired");
        }
        List<SavedPaymentMethod> all = mine(userId);
        if (all.size() >= MAX) throw new ConflictException("Your wallet is full (" + MAX + " cards). Delete one first.");
        String last4 = number.substring(number.length() - 4);
        String brand = brand(number);
        for (SavedPaymentMethod m : all) {
            if (m.getLast4().equals(last4) && m.getBrand().equals(brand) && m.getExpiryMonth() == month && m.getExpiryYear() == year) {
                throw new ConflictException("This card is already saved");
            }
        }
        String token = gateway.tokenize(number);
        if (token == null) throw new BusinessRuleException("This card cannot be saved. In test mode use a Stripe test card, e.g. 4242 4242 4242 4242");
        boolean first = all.isEmpty();
        if (first || makeDefault) clearDefault(all);
        SavedPaymentMethod saved = new SavedPaymentMethod(userId, brand, last4, month, year, gateway.name(), token);
        saved.setDefault(first || makeDefault);
        return repo.save(saved);
    }

    public MethodResponse makeDefault(String email, long id) {
        Long userId = user(email).getId();
        SavedPaymentMethod m = owned(id, userId);
        clearDefault(mine(userId));
        m.setDefault(true);
        return MethodResponse.from(repo.save(m), YearMonth.now(clock));
    }

    public void delete(String email, long id) {
        Long userId = user(email).getId();
        SavedPaymentMethod m = owned(id, userId);
        boolean wasDefault = m.isDefault();
        repo.delete(m);
        repo.flush();
        if (wasDefault) {
            mine(userId).stream().max(Comparator.comparing(SavedPaymentMethod::getCreatedAt).thenComparing(SavedPaymentMethod::getId))
                    .ifPresent(next -> { next.setDefault(true); repo.save(next); });
        }
    }

    /** A card of this customer, for paying; 404 for anyone else's. */
    @Transactional(readOnly = true)
    public SavedPaymentMethod ownedBy(String email, long id) {
        return owned(id, user(email).getId());
    }

    /** Visa 4, Mastercard 51-55 and 2221-2720, American Express 34/37, Discover 6011/65; "Card" otherwise. */
    static String brand(String n) {
        if (n.startsWith("4")) return "Visa";
        if (n.startsWith("34") || n.startsWith("37")) return "Amex";
        if (n.length() >= 4) {
            int p4 = Integer.parseInt(n.substring(0, 4));
            int p2 = Integer.parseInt(n.substring(0, 2));
            if ((p2 >= 51 && p2 <= 55) || (p4 >= 2221 && p4 <= 2720)) return "Mastercard";
            if (p4 == 6011 || p2 == 65) return "Discover";
        }
        return "Card";
    }

    private void clearDefault(List<SavedPaymentMethod> all) {
        for (SavedPaymentMethod m : all) {
            if (m.isDefault()) {
                m.setDefault(false);
                repo.save(m);
            }
        }
        repo.flush();
    }

    private List<SavedPaymentMethod> mine(Long userId) {
        return repo.findByUserIdOrderByIsDefaultDescCreatedAtDescIdDesc(userId);
    }

    private SavedPaymentMethod owned(long id, Long userId) {
        return repo.findByIdAndUserId(id, userId).orElseThrow(() -> new NotFoundException("Saved card not found: " + id));
    }

    private AppUser user(String email) {
        return users.findByEmail(email).orElseThrow(() -> new NotFoundException("User not found"));
    }
}