Skip to content

Commit

Permalink
feat: add support for Google Wallet passes
Browse files Browse the repository at this point in the history
  • Loading branch information
dsluijk committed Jul 28, 2024
1 parent 90744b5 commit 46b60aa
Show file tree
Hide file tree
Showing 20 changed files with 424 additions and 42 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ dependencies {
testImplementation 'com.tngtech.java:junit-dataprovider:1.5.0'

implementation 'org.javatuples:javatuples:1.2'

implementation 'com.auth0:java-jwt:4.4.0'
implementation 'com.google.api-client:google-api-client:2.6.0'
implementation 'com.google.apis:google-api-services-walletobjects:v1-rev20240723-2.0.0'
}

test {
Expand Down
9 changes: 7 additions & 2 deletions config/application-devcontainer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,18 @@ wisvch.connect:

# CH Events Configuration
wisvch.events:
image.path: http://localhost:8080/events/api/v1/documents/
image.path: http://localhost:8080/api/v1/documents/

# CH mollie api key
mollie:
apikey: test
clientUri: http://localhost:8080/events
clientUri: http://localhost:8080

links:
gtc: https://ch.tudelft.nl
passes: https://ch.tudelft.nl/passes

googleWallet:
issuerId: 3388000000022297569
origin: http://localhost:8080
baseUrl: https://ch.tudelft.nl/events
7 changes: 6 additions & 1 deletion config/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,9 @@ management:

links:
gtc: https://ch.tudelft.nl/wp-content/uploads/Deelnemersvoorwaarden_versie_12_06_2023.pdf
passes: passes
passes: passes

googleWallet:
issuerId: 3388000000022297569
origin: https://ch.tudelft.nl/events
baseUrl: https://ch.tudelft.nl/events
7 changes: 6 additions & 1 deletion config/application.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,9 @@ mollie:

links:
gtc: https://ch.tudelft.nl/wp-content/uploads/Deelnemersvoorwaarden_versie_12_06_2023.pdf
passes: https://ch.tudelft.nl/passes
passes: https://ch.tudelft.nl/passes

googleWallet:
issuerId: 3388000000022297569
origin: https://ch.tudelft.nl/events
baseUrl: https://ch.tudelft.nl/events
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ch.wisv.events.core.service.googlewallet;

import ch.wisv.events.core.exception.normal.TicketPassFailedException;
import ch.wisv.events.core.model.ticket.Ticket;

public interface GoogleWalletService {
/**
* Get Google Wallet pass for a Ticket.
* @param ticket of type Ticket.
* @return A link the user can use to add the ticket to their wallet.
* @throws TicketPassFailedException when pass is not generated
*/
String getPass(Ticket ticket) throws TicketPassFailedException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package ch.wisv.events.core.service.googlewallet;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import ch.wisv.events.core.exception.normal.TicketPassFailedException;
import ch.wisv.events.core.model.event.Event;
import ch.wisv.events.core.model.product.Product;
import ch.wisv.events.core.model.ticket.Ticket;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.google.api.services.walletobjects.WalletobjectsScopes;
import com.google.api.services.walletobjects.model.*;
import com.google.auth.oauth2.ServiceAccountCredentials;

import jakarta.validation.constraints.NotNull;

import java.io.IOException;
import java.security.interfaces.RSAPrivateKey;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;

@Service
public class GoogleWalletServiceImpl implements GoogleWalletService {
/** Service account credentials for Google Wallet APIs. */
public static ServiceAccountCredentials credentials;

@Value("${googleWallet.issuerId}")
@NotNull
private String issuerId;

@Value("${googleWallet.baseUrl}")
@NotNull
private String baseUrl;

@Value("${googleWallet.origin}")
@NotNull
private String origin;

@Value("${links.gtc}")
@NotNull
private String linkGTC;

/**
* Get Google Wallet pass for a Ticket.
*
* @param ticket of type Ticket.
* @return A link the user can use to add the ticket to their wallet.
* @throws TicketPassFailedException when pass is not generated
*/
public String getPass(Ticket ticket) throws TicketPassFailedException {
if (credentials == null) {
try {
credentials = (ServiceAccountCredentials) ServiceAccountCredentials
.getApplicationDefault()
.createScoped(List.of(WalletobjectsScopes.WALLET_OBJECT_ISSUER));
credentials.refresh();
} catch (IOException e) {
System.out.println("WARN: Failed to authenticate with Google");
return "https://pay.google.com/gp/v/save/FAILED";
}
}

Product product = ticket.getProduct();
EventTicketClass newClass = this.createClass(product);
EventTicketObject newObject = this.createObject(ticket);

HashMap<String, Object> claims = new HashMap<String, Object>();
claims.put("iss", credentials.getClientEmail());
claims.put("aud", "google");
claims.put("origins", List.of(origin));
claims.put("typ", "savetowallet");
claims.put("iat", Instant.now().getEpochSecond());

HashMap<String, Object> payload = new HashMap<String, Object>();
payload.put("eventTicketClasses", List.of(newClass));
payload.put("eventTicketObjects", List.of(newObject));
claims.put("payload", payload);

Algorithm algorithm = Algorithm.RSA256(
null, (RSAPrivateKey) credentials.getPrivateKey());
String token = JWT.create().withPayload(claims).sign(algorithm);

return String.format("https://pay.google.com/gp/v/save/%s", token);
}

/**
* Create the passes class based on the product.
*
* @param product The product to base it on.
* @return A Google compatible Ticket class.
*/
private EventTicketClass createClass(Product product) {
String homePage = product.getEvent().hasExternalProductUrl()
? product.getEvent().getExternalProductUrl()
: baseUrl;
Uri tnc = new Uri()
.setUri(linkGTC)
.setDescription("Terms & Conditions")
.setId("LINK_GTC");

return new EventTicketClass()
.setId(this.getClassId(product))
.setIssuerName("Christiaan Huygens")
.setReviewStatus("UNDER_REVIEW")
.setHexBackgroundColor("#1e274a")
.setEventName(this.makeLocalString(this.formatProduct(product)))
.setWideLogo(this.makeImage(String.format("%s/images/ch-logo.png", baseUrl)))
.setLogo(this.makeImage(String.format("%s/icons/apple-touch-icon.png", baseUrl)))
.setLinksModuleData(new LinksModuleData().setUris(Arrays.asList(tnc)))
.setHomepageUri(new Uri()
.setUri(homePage)
.setDescription("Events"))
.setVenue(new EventVenue()
.setName(this.makeLocalString(product.getEvent().getLocation()))
.setAddress(this.makeLocalString("Mekelweg 4, 2628 CD Delft")))
.setDateTime(new EventDateTime()
.setStart(this.formatDate(product.getEvent().getStart()))
.setEnd(this.formatDate(product.getEvent().getEnding())));
}

private EventTicketObject createObject(Ticket ticket) {
Money cost = new Money().setCurrencyCode("EUR").setMicros((long) (ticket.getProduct().cost * 1000000));
Uri tnc = new Uri()
.setUri(linkGTC)
.setDescription("Terms & Conditions")
.setId("LINK_GTC");

return new EventTicketObject()
.setId(this.getObjectId(ticket))
.setClassId(this.getClassId(ticket.getProduct()))
.setState("ACTIVE")
.setTicketNumber(ticket.getKey())
.setTicketHolderName(ticket.getOwner().getName())
.setHexBackgroundColor("#1e274a")
.setFaceValue(cost)
.setBarcode(new Barcode().setType("QR_CODE").setValue(ticket.getUniqueCode()))
.setLinksModuleData(new LinksModuleData().setUris(Arrays.asList(tnc)));
}

/**
* Get the ID of a product.
*
* @param product The product to derive the ID.
* @return The ID
*/
private String getClassId(Product product) {
return String.format("%s.%s", issuerId, product.getKey());
}

/**
* Get the object ID of a ticket.
*
* @param ticket The ticket to get the ID for.
* @return The ID
*/
private String getObjectId(Ticket ticket) {
return String.format("%s-%s", this.getClassId(ticket.getProduct()), ticket.getKey());
}

/**
* Format the product name accorting to if they have a second product
*
* @param product
* @return
*/
private String formatProduct(Product product) {
Event event = product.getEvent();

if (event.getProducts().size() <= 1) {
return event.getTitle();
} else {
return String.format("%s - %s", event.getTitle(), product.getTitle());
}
}

/**
* Format a local date to the string format Google expects.
*
* @param localDate The Java date object.
* @return The date in string form.
*/
private String formatDate(LocalDateTime localDate) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX");
String formattedDate = localDate.atOffset(ZoneOffset.UTC).format(formatter);
return formattedDate;
}

private LocalizedString makeLocalString(String str) {
TranslatedString defaultLang = new TranslatedString()
.setLanguage("en-US")
.setValue(str);
return new LocalizedString().setDefaultValue(defaultLang);
}

private Image makeImage(String src) {
ImageUri uri = new ImageUri().setUri(src);
return new Image().setSourceUri(uri);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import ch.wisv.events.core.service.ticket.TicketService;
import ch.wisv.events.core.util.QrCode;
import com.google.zxing.WriterException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
Expand Down Expand Up @@ -53,14 +52,18 @@ public class MailServiceImpl implements MailService {
@NotNull
private String linkGTC;

/** Link to GTC. */
@Value("${googleWallet.origin}")
@NotNull
private String origin;

/**
* MailServiceImpl constructor.
*
* @param mailSender of type JavaMailSender
* @param templateEngine of type templateEngine
* @param ticketService of type TicketService
*/
@Autowired
public MailServiceImpl(JavaMailSender mailSender, SpringTemplateEngine templateEngine, TicketService ticketService) {
this.mailSender = mailSender;
this.templateEngine = templateEngine;
Expand All @@ -80,6 +83,7 @@ public void sendOrderConfirmation(Order order, List<Ticket> tickets) {
ctx.setVariable("tickets", tickets);
ctx.setVariable("redirectLinks", tickets.stream().anyMatch(ticket -> ticket.getProduct().getRedirectUrl() != null));
ctx.setVariable("linkGTC", linkGTC);
ctx.setVariable("origin", origin);
String subject = String.format("Ticket overview %s", order.getPublicReference().substring(0, ORDER_NUMBER_LENGTH));

this.sendMailWithContent(order.getOwner().getEmail(), subject, this.templateEngine.process("mail/order", ctx), tickets);
Expand Down Expand Up @@ -170,6 +174,9 @@ private void sendMailWithContent(String recipientEmail, String subject, String c
message.addInline("ch-logo.png", new ClassPathResource("/static/images/ch-logo.png"), "image/png");

if(tickets != null) {
message.addInline("apple-wallet.svg", new ClassPathResource("/static/images/apple-wallet.svg"), "image/svg+xml");
message.addInline("google-wallet.svg", new ClassPathResource("/static/images/google-wallet.svg"), "image/svg+xml");

for (Ticket ticket : tickets) {
String uniqueCode = ticket.getUniqueCode();
// Retrieve and return barcode (LEGACY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,11 @@ public interface TicketService {
*/
byte[] getApplePass(Ticket ticket) throws TicketPassFailedException;

/**
* Get Google Wallet pass for a Ticket.
* @param ticket of type Ticket.
* @return A link the user can use to add the ticket to their wallet.
* @throws TicketPassFailedException when pass is not generated
*/
String getGooglePass(Ticket ticket) throws TicketPassFailedException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.*;

import ch.wisv.events.core.service.event.EventService;
import ch.wisv.events.core.service.googlewallet.GoogleWalletService;
import ch.wisv.events.core.util.QrCode;
import com.google.zxing.WriterException;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -41,6 +42,11 @@ public class TicketServiceImpl implements TicketService {
*/
private final EventService eventService;

/**
* GoogleWalletService.
*/
private final GoogleWalletService googleWalletService;

@Value("${links.passes}")
@NotNull
private String passesLink;
Expand All @@ -51,9 +57,10 @@ public class TicketServiceImpl implements TicketService {
* @param ticketRepository of type TicketRepository
* @param eventService of type EventService
*/
public TicketServiceImpl(TicketRepository ticketRepository, EventService eventService) {
public TicketServiceImpl(TicketRepository ticketRepository, EventService eventService, GoogleWalletService googleWalletService) {
this.ticketRepository = ticketRepository;
this.eventService = eventService;
this.googleWalletService = googleWalletService;
}

/**
Expand Down Expand Up @@ -285,4 +292,14 @@ public byte[] getApplePass(Ticket ticket) throws TicketPassFailedException {
throw new TicketPassFailedException(e.getMessage());
}
}

/**
* Get Google Wallet pass for a Ticket.
* @param ticket of type Ticket.
* @return A link the user can use to add the ticket to their wallet.
* @throws TicketPassFailedException when pass is not generated
*/
public String getGooglePass(Ticket ticket) throws TicketPassFailedException {
return this.googleWalletService.getPass(ticket);
}
}
Loading

0 comments on commit 46b60aa

Please sign in to comment.