Skip to content

Commit

Permalink
feat(vehicle-service): add stripe webhook to enable payment methods
Browse files Browse the repository at this point in the history
  • Loading branch information
vincenzo.corso committed Mar 23, 2024
1 parent ca4b188 commit 46a3180
Show file tree
Hide file tree
Showing 18 changed files with 205 additions and 29 deletions.
2 changes: 1 addition & 1 deletion payment-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ dependencies {

implementation 'io.quarkus:quarkus-resteasy-jackson'
implementation 'io.quarkus:quarkus-hibernate-validator'
implementation 'io.quarkus:quarkus-hibernate-orm-panache'
implementation 'io.quarkus:quarkus-hibernate-orm'
implementation 'io.quarkus:quarkus-jdbc-postgresql'
implementation 'io.quarkus:quarkus-smallrye-reactive-messaging-kafka'
implementation 'com.stripe:stripe-java:24.2.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.persistence.jpa;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "customers")
public class CustomerEntity extends PanacheEntityBase {
@NoArgsConstructor
public class CustomerEntity {
@Id
@Column(name = "customer_id")
public String customerId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.persistence.jpa;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.*;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "payment_methods")
public class PaymentMethodEntity extends PanacheEntityBase {
@NoArgsConstructor
public class PaymentMethodEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "payment_method_id")
public String id;

@Column(name = "external_id", nullable = false)
@Column(name = "external_id")
public String externalId;

@Column(name = "customer_id", nullable = false)
@Column(name = "customer_id")
public String customerId;

@Column(name = "state", nullable = false)
@Column(name = "state")
public String state;

@Column(name = "metadata", nullable = false)
@Column(name = "metadata")
public String metadata;

@Version
@Column(name = "version", nullable = false)
@Column(name = "version")
public Long version;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public PaymentMethodEntity convertToEntity(PaymentMethod paymentMethod) {
return paymentMethodEntity;
}

private String encodeMetadata(Map<String, String> metadata) {
public String encodeMetadata(Map<String, String> metadata) {
try {
return objectMapper.writeValueAsString(metadata);
} catch (Exception ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,35 @@
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.SetupIntentCreateParams;
import it.vincenzocorso.carsharing.paymentservice.adapters.persistence.PaymentMethodWrapper;
import it.vincenzocorso.carsharing.paymentservice.adapters.stripe.SetupIntentMetadataKey;
import it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethod;
import it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethodDetails;
import it.vincenzocorso.carsharing.paymentservice.domain.ports.out.PaymentMethodRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import static com.stripe.param.SetupIntentCreateParams.Usage.OFF_SESSION;
import static it.vincenzocorso.carsharing.paymentservice.adapters.stripe.SetupIntentMetadataKey.PAYMENT_METHOD_ID;

@ApplicationScoped
@AllArgsConstructor
@Slf4j
public class PaymentMethodJPARepositoryAdapter implements PaymentMethodRepository {
private static final String STRIPE_CLIENT_SECRET = "stripe__client_secret";

final EntityManager entityManager;
final PaymentMethodEntityMapper entityMapper;
final StripeClient stripeClient;

@Override
public Optional<PaymentMethod> findById(String paymentMethodId) {
return PaymentMethodEntity.<PaymentMethodEntity>findByIdOptional(paymentMethodId)
return Optional.ofNullable(entityManager.find(PaymentMethodEntity.class, paymentMethodId))
.map(entityMapper::convertFromEntity);
}

Expand All @@ -38,20 +44,28 @@ public PaymentMethod save(PaymentMethod paymentMethod) {
String customerId = paymentMethod.getPaymentMethodDetails().getCustomerId();
Customer customer = this.retrieveStripeCustomer(customerId);

SetupIntent setupIntent = null;
PaymentMethodEntity paymentMethodEntity = this.entityMapper.convertToEntity(paymentMethod);

if(!hasStripeSetupIntentBeenCreated(paymentMethod)) {
setupIntent = createSetupIntent(paymentMethod.getPaymentMethodDetails(), customer);
paymentMethodEntity.id = UUID.randomUUID().toString();

Map<SetupIntentMetadataKey, String> setupIntentMetadata = Map.ofEntries(Map.entry(PAYMENT_METHOD_ID, paymentMethodEntity.id));
SetupIntent setupIntent = createSetupIntent(paymentMethod.getPaymentMethodDetails(), customer, setupIntentMetadata);

paymentMethod.getMetadata().put(STRIPE_CLIENT_SECRET, setupIntent.getClientSecret());
paymentMethodEntity.metadata = entityMapper.encodeMetadata(paymentMethod.getMetadata());
paymentMethodEntity.externalId = setupIntent.getId();
} else {
paymentMethodEntity = entityManager.merge(paymentMethodEntity);
}

PaymentMethodEntity paymentMethodEntity = this.entityMapper.convertToEntity(paymentMethod);
paymentMethodEntity.externalId = (setupIntent != null) ? setupIntent.getId() : null;
paymentMethodEntity.persist();
entityManager.persist(paymentMethodEntity);

return this.entityMapper.convertFromEntity(paymentMethodEntity);
}

Customer retrieveStripeCustomer(String customerId) {
return CustomerEntity.<CustomerEntity>findByIdOptional(customerId)
return Optional.ofNullable(entityManager.find(CustomerEntity.class, customerId))
.map(c -> {
try {
return this.stripeClient.customers().retrieve(c.externalCustomerId);
Expand All @@ -69,7 +83,7 @@ Customer retrieveStripeCustomer(String customerId) {
CustomerEntity customerEntity = new CustomerEntity();
customerEntity.customerId = customerId;
customerEntity.externalCustomerId = customer.getId();
customerEntity.persist();
entityManager.persist(customerEntity);
return customer;
} catch (StripeException ex) {
log.error("An error occurred while creating the stripe customer (customerId: {}): ", customerId, ex);
Expand All @@ -82,14 +96,16 @@ private boolean hasStripeSetupIntentBeenCreated(PaymentMethod paymentMethod) {
return paymentMethod instanceof PaymentMethodWrapper;
}

SetupIntent createSetupIntent(PaymentMethodDetails paymentMethodDetails, Customer customer) {
SetupIntent createSetupIntent(PaymentMethodDetails paymentMethodDetails, Customer customer, Map<SetupIntentMetadataKey, String> metadata) {
try {
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
SetupIntentCreateParams.Builder paramsBuilder = SetupIntentCreateParams.builder()
.addPaymentMethodType("sepa_debit")
.setCustomer(customer.getId())
.setUsage(OFF_SESSION)
.build();
return stripeClient.setupIntents().create(params);
.setUsage(OFF_SESSION);

metadata.forEach((key, value) -> paramsBuilder.putMetadata(key.getKeyName(), value));

return stripeClient.setupIntents().create(paramsBuilder.build());
} catch (StripeException ex) {
log.error("An error occurred while creating the setup intent (details: {}): ", paymentMethodDetails, ex);
throw new RuntimeException(ex.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.stripe;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum SetupIntentMetadataKey {
PAYMENT_METHOD_ID("internal__payment_method_id");

private final String keyName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.webhook;

public record StripeEvent(
String id,
String object,
String apiVersion,
String created,
String type,
StripeEventData data
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.webhook;

public record StripeEventData(
StripeEventDataObject object
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.webhook;

import java.util.Map;

public record StripeEventDataObject(
String id,
String object,
Map<String, String> metadata
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package it.vincenzocorso.carsharing.paymentservice.adapters.webhook;

import it.vincenzocorso.carsharing.common.exceptions.InternalServerException;
import it.vincenzocorso.carsharing.paymentservice.adapters.stripe.SetupIntentMetadataKey;
import it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethod;
import it.vincenzocorso.carsharing.paymentservice.domain.ports.in.SavePaymentMethod;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import lombok.AllArgsConstructor;
import lombok.extern.flogger.Flogger;
import lombok.extern.slf4j.Slf4j;

import java.util.Optional;

import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
import static jakarta.ws.rs.core.Response.Status.OK;

@Path("/stripe/webhook")
@Produces(APPLICATION_JSON)
@Consumes(APPLICATION_JSON)
@Slf4j
@AllArgsConstructor
public class StripeWebhookController {
final SavePaymentMethod savePaymentMethod;

@POST
public Response handleStripeEvents(StripeEvent event) {
log.info("Received event of type " + event.type() + ": " + event);

switch (event.type()) {
case "setup_intent.succeeded" -> handleSetupIntentSucceededEvent(event);
default -> log.info("A stripe event was skipped: " + event);
}

return Response.status(OK).build();
}

@Transactional
void handleSetupIntentSucceededEvent(StripeEvent event) {
String paymentMethodId = Optional.ofNullable(event.data())
.map(StripeEventData::object)
.map(StripeEventDataObject::metadata)
.map(metadata -> metadata.getOrDefault(SetupIntentMetadataKey.PAYMENT_METHOD_ID.getKeyName(), null))
.orElseThrow(() -> new RuntimeException("Could not find the payment method id"));

PaymentMethod enabledPaymentMethod = savePaymentMethod.enablePaymentMethod(paymentMethodId);
log.info("Enabled payment method {}", enabledPaymentMethod.getId());
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package it.vincenzocorso.carsharing.paymentservice.domain;

import it.vincenzocorso.carsharing.common.messaging.events.DomainEvent;
import it.vincenzocorso.carsharing.common.messaging.events.ResultWithEvents;
import it.vincenzocorso.carsharing.paymentservice.domain.exceptions.PaymentMethodNotFoundException;
import it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethod;
import it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethodDetails;
import it.vincenzocorso.carsharing.paymentservice.domain.ports.in.SavePaymentMethod;
import it.vincenzocorso.carsharing.paymentservice.domain.ports.out.PaymentMethodRepository;
import lombok.AllArgsConstructor;

import java.util.List;

@AllArgsConstructor
public class PaymentService implements SavePaymentMethod {
final PaymentMethodRepository paymentMethodRepository;
Expand All @@ -16,7 +20,19 @@ public PaymentMethod savePaymentMethod(PaymentMethodDetails details) {
ResultWithEvents<PaymentMethod> resultWithEvents = PaymentMethod.create(details);
PaymentMethod savedPaymentMethod = this.paymentMethodRepository.save(resultWithEvents.result);

// publish events
// TODO: publish events

return savedPaymentMethod;
}

@Override
public PaymentMethod enablePaymentMethod(String paymentMethodId) {
PaymentMethod paymentMethod = this.paymentMethodRepository.findById(paymentMethodId)
.orElseThrow(() -> new PaymentMethodNotFoundException(paymentMethodId));
List<DomainEvent> events = paymentMethod.enable();
PaymentMethod savedPaymentMethod = paymentMethodRepository.save(paymentMethod);

// TODO: publish events

return savedPaymentMethod;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package it.vincenzocorso.carsharing.paymentservice.domain.events;

import it.vincenzocorso.carsharing.common.messaging.events.DomainEvent;

public record PaymentMethodStateTransitionEvent(String oldState, String newState) implements DomainEvent {
@Override
public String getType() {
return "PAYMENT_METHOD_STATE_TRANSITION_EVENT";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package it.vincenzocorso.carsharing.paymentservice.domain.exceptions;

import lombok.NoArgsConstructor;

@NoArgsConstructor
public class IllegalPaymentMethodStateTransitionException extends RuntimeException {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package it.vincenzocorso.carsharing.paymentservice.domain.exceptions;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class PaymentMethodNotFoundException extends RuntimeException {
private final String paymentMethodId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
import it.vincenzocorso.carsharing.common.messaging.events.DomainEvent;
import it.vincenzocorso.carsharing.common.messaging.events.ResultWithEvents;
import it.vincenzocorso.carsharing.paymentservice.domain.events.PaymentMethodSavedEvent;
import it.vincenzocorso.carsharing.paymentservice.domain.events.PaymentMethodStateTransitionEvent;
import it.vincenzocorso.carsharing.paymentservice.domain.exceptions.IllegalPaymentMethodStateTransitionException;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethodState.PENDING;
import static it.vincenzocorso.carsharing.paymentservice.domain.models.PaymentMethodState.*;

@AllArgsConstructor
@Getter
Expand All @@ -33,4 +35,24 @@ public static ResultWithEvents<PaymentMethod> create(PaymentMethodDetails paymen
.build();
return ResultWithEvents.of(paymentMethod, List.of(domainEvent));
}

public List<DomainEvent> enable() {
if(!PENDING.equals(this.state)) {
throw new IllegalPaymentMethodStateTransitionException();
}

this.state = ENABLED;

return List.of(new PaymentMethodStateTransitionEvent(PENDING.name(), ENABLED.name()));
}

public List<DomainEvent> disable() {
if(!PENDING.equals(this.state)) {
throw new IllegalPaymentMethodStateTransitionException();
}

this.state = DISABLED;

return List.of(new PaymentMethodStateTransitionEvent(PENDING.name(), DISABLED.name()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

public enum PaymentMethodState {
PENDING,
ACTIVE,
INACTIVE
ENABLED,
DISABLED
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

public interface SavePaymentMethod {
PaymentMethod savePaymentMethod(PaymentMethodDetails details);
PaymentMethod enablePaymentMethod(String paymentMethodId);
}
Loading

0 comments on commit 46a3180

Please sign in to comment.