Add support for one-time PayPal donations

This commit is contained in:
Chris Eager
2022-11-23 14:15:38 -06:00
committed by Chris Eager
parent d40d2389a9
commit 2ecbb18fe5
23 changed files with 35824 additions and 109 deletions

View File

@@ -0,0 +1,236 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import com.apollographql.apollo3.api.ApolloResponse;
import com.apollographql.apollo3.api.Operation;
import com.apollographql.apollo3.api.Operations;
import com.apollographql.apollo3.api.Optional;
import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;
import com.braintree.graphql.client.type.ChargePaymentMethodInput;
import com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.CustomFieldInput;
import com.braintree.graphql.client.type.MonetaryAmountInput;
import com.braintree.graphql.client.type.PayPalExperienceProfileInput;
import com.braintree.graphql.client.type.PayPalIntent;
import com.braintree.graphql.client.type.PayPalLandingPageType;
import com.braintree.graphql.client.type.PayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;
import com.braintree.graphql.client.type.TransactionDescriptorInput;
import com.braintree.graphql.client.type.TransactionInput;
import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation;
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import javax.ws.rs.ServiceUnavailableException;
import okio.Buffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
class BraintreeGraphqlClient {
// required header value, recommended to be the date the integration began
// https://graphql.braintreepayments.com/guides/making_api_calls/#the-braintree-version-header
private static final String BRAINTREE_VERSION = "2022-10-01";
private static final Logger logger = LoggerFactory.getLogger(BraintreeGraphqlClient.class);
private final FaultTolerantHttpClient httpClient;
private final URI graphqlUri;
private final String authorizationHeader;
BraintreeGraphqlClient(final FaultTolerantHttpClient httpClient,
final String graphqlUri,
final String publicKey,
final String privateKey) {
this.httpClient = httpClient;
try {
this.graphqlUri = new URI(graphqlUri);
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid URI", e);
}
// “public”/“private” key is a bit of a misnomer, but we follow the upstream nomenclature
// they are used for Basic auth similar to “client key”/“client secret” credentials
this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((publicKey + ":" + privateKey).getBytes());
}
CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> createPayPalOneTimePayment(
final BigDecimal amount, final String currency, final String returnUrl,
final String cancelUrl, final String locale) {
final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl,
cancelUrl, locale);
final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse ->
{
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final CreatePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
return data.createPayPalOneTimePayment;
});
}
private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount,
String currency, String returnUrl, String cancelUrl, String locale) {
return new CreatePayPalOneTimePaymentInput(
Optional.absent(),
Optional.absent(), // merchant account ID will be specified when charging
new MonetaryAmountInput(amount.toString(), currency), // this could potentially use a CustomScalarAdapter
cancelUrl,
Optional.absent(),
PayPalIntent.SALE,
Optional.absent(),
Optional.present(false), // offerPayLater,
Optional.absent(),
Optional.present(
new PayPalExperienceProfileInput(Optional.present("Signal"),
Optional.present(false),
Optional.present(PayPalLandingPageType.LOGIN),
Optional.present(locale),
Optional.absent())),
Optional.absent(),
Optional.absent(),
returnUrl,
Optional.absent(),
Optional.absent()
);
}
CompletableFuture<TokenizePayPalOneTimePaymentMutation.TokenizePayPalOneTimePayment> tokenizePayPalOneTimePayment(
final String payerId, final String paymentId, final String paymentToken) {
final TokenizePayPalOneTimePaymentInput input = new TokenizePayPalOneTimePaymentInput(
Optional.absent(),
Optional.absent(), // merchant account ID will be specified when charging
new PayPalOneTimePaymentInput(payerId, paymentId, paymentToken)
);
final TokenizePayPalOneTimePaymentMutation mutation = new TokenizePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse -> {
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final TokenizePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
return data.tokenizePayPalOneTimePayment;
});
}
CompletableFuture<ChargePayPalOneTimePaymentMutation.ChargePaymentMethod> chargeOneTimePayment(
final String paymentMethodId, final BigDecimal amount, final String merchantAccount, final long level) {
final List<CustomFieldInput> customFields = List.of(
new CustomFieldInput("level", Optional.present(Long.toString(level))));
final ChargePaymentMethodInput input = buildChargePaymentMethodInput(paymentMethodId, amount, merchantAccount,
customFields);
final ChargePayPalOneTimePaymentMutation mutation = new ChargePayPalOneTimePaymentMutation(input);
final HttpRequest request = buildRequest(mutation);
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(httpResponse -> {
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
final ChargePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse,
mutation);
return data.chargePaymentMethod;
});
}
private static ChargePaymentMethodInput buildChargePaymentMethodInput(String paymentMethodId, BigDecimal amount,
String merchantAccount, List<CustomFieldInput> customFields) {
return new ChargePaymentMethodInput(
Optional.absent(),
paymentMethodId,
new TransactionInput(
// documented as “amount: whole number, or exactly two or three decimal places”
amount.toString(), // this could potentially use a CustomScalarAdapter
Optional.present(merchantAccount),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.present(customFields),
Optional.present(new TransactionDescriptorInput(
Optional.present("Signal Technology Foundation"),
Optional.absent(),
Optional.absent()
)),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent()
)
);
}
/**
* Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise
* throws a {@link ServiceUnavailableException}.
*/
private <T extends Operation<U>, U extends Operation.Data> U assertSuccessAndExtractData(
HttpResponse<String> httpResponse, T operation) {
if (httpResponse.statusCode() != 200) {
logger.warn("Received HTTP response status {} ({})", httpResponse.statusCode(),
httpResponse.headers().firstValue("paypal-debug-id").orElse("<debug id absent>"));
throw new ServiceUnavailableException();
}
ApolloResponse<U> response = Operations.parseJsonResponse(operation, httpResponse.body());
if (response.hasErrors() || response.data == null) {
//noinspection ConstantConditions
response.errors.forEach(
error -> {
final Object legacyCode = java.util.Optional.ofNullable(error.getExtensions())
.map(extensions -> extensions.get("legacyCode"))
.orElse("<none>");
logger.warn("Received GraphQL error for {}: \"{}\" (legacyCode: {})",
response.operation.name(), error.getMessage(), legacyCode);
});
throw new ServiceUnavailableException();
}
return response.data;
}
private HttpRequest buildRequest(final Operation<?> operation) {
final Buffer buffer = new Buffer();
Operations.composeJsonRequest(operation, new BufferedSinkJsonWriter(buffer));
return HttpRequest.newBuilder()
.uri(graphqlUri)
.method("POST", HttpRequest.BodyPublishers.ofString(buffer.readUtf8()))
.header("Content-Type", "application/json")
.header("Authorization", authorizationHeader)
.header("Braintree-Version", BRAINTREE_VERSION)
.build();
}
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import com.braintreegateway.BraintreeGateway;
import com.braintreegateway.ResourceCollection;
import com.braintreegateway.Transaction;
import com.braintreegateway.TransactionSearchRequest;
import com.braintreegateway.exceptions.NotFoundException;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
public class BraintreeManager implements SubscriptionProcessorManager {
private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094";
private final BraintreeGateway braintreeGateway;
private final BraintreeGraphqlClient braintreeGraphqlClient;
private final Executor executor;
private final Set<String> supportedCurrencies;
private final Map<String, String> currenciesToMerchantAccounts;
public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey,
final String braintreePrivateKey,
final String braintreeEnvironment,
final Set<String> supportedCurrencies,
final Map<String, String> currenciesToMerchantAccounts,
final String graphqlUri,
final CircuitBreakerConfiguration circuitBreakerConfiguration,
final Executor executor) {
this.braintreeGateway = new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey,
braintreePrivateKey);
this.supportedCurrencies = supportedCurrencies;
this.currenciesToMerchantAccounts = currenciesToMerchantAccounts;
final FaultTolerantHttpClient httpClient = FaultTolerantHttpClient.newBuilder()
.withName("braintree-graphql")
.withCircuitBreaker(circuitBreakerConfiguration)
.withExecutor(executor)
.build();
this.braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, graphqlUri, braintreePublicKey,
braintreePrivateKey);
this.executor = executor;
}
@Override
public Set<String> getSupportedCurrencies() {
return supportedCurrencies;
}
@Override
public SubscriptionProcessor getProcessor() {
return SubscriptionProcessor.BRAINTREE;
}
@Override
public boolean supportsPaymentMethod(final PaymentMethod paymentMethod) {
return paymentMethod == PaymentMethod.PAYPAL;
}
@Override
public boolean supportsCurrency(final String currency) {
return supportedCurrencies.contains(currency.toLowerCase(Locale.ROOT));
}
@Override
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
return CompletableFuture.supplyAsync(() -> {
try {
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
return new PaymentDetails(transaction.getGraphQLId(),
transaction.getCustomFields(),
getPaymentStatus(transaction.getStatus()),
transaction.getCreatedAt().toInstant());
} catch (final NotFoundException e) {
return null;
}
}, executor);
}
@Override
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) {
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
}
@Override
public CompletableFuture<String> createPaymentMethodSetupToken(final String customerId) {
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
}
public CompletableFuture<PayPalOneTimePaymentApprovalDetails> createOneTimePayment(String currency, long amount,
String locale, String returnUrl, String cancelUrl) {
return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount),
currency.toUpperCase(Locale.ROOT), returnUrl,
cancelUrl, locale)
.thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId));
}
public CompletableFuture<PayPalChargeSuccessDetails> captureOneTimePayment(String payerId, String paymentId,
String paymentToken, String currency, long amount, long level) {
return braintreeGraphqlClient.tokenizePayPalOneTimePayment(payerId, paymentId, paymentToken)
.thenCompose(response -> braintreeGraphqlClient.chargeOneTimePayment(
response.paymentMethod.id,
convertApiAmountToBraintreeAmount(currency, amount),
currenciesToMerchantAccounts.get(currency.toLowerCase(Locale.ROOT)),
level)
.thenComposeAsync(chargeResponse -> {
final PaymentStatus paymentStatus = getPaymentStatus(chargeResponse.transaction.status);
if (paymentStatus == PaymentStatus.SUCCEEDED || paymentStatus == PaymentStatus.PROCESSING) {
return CompletableFuture.completedFuture(new PayPalChargeSuccessDetails(chargeResponse.transaction.id));
}
// the GraphQL/Apollo interfaces are a tad unwieldy for this type of status checking
final Transaction unsuccessfulTx = braintreeGateway.transaction().find(chargeResponse.transaction.id);
if (PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE.equals(unsuccessfulTx.getProcessorResponseCode())
|| Transaction.GatewayRejectionReason.DUPLICATE.equals(
unsuccessfulTx.getGatewayRejectionReason())) {
// the payment has already been charged - maybe a previous call timed out or was interrupted -
// in any case, check for a successful transaction with the paymentId
final ResourceCollection<Transaction> search = braintreeGateway.transaction()
.search(new TransactionSearchRequest()
.paypalPaymentId().is(paymentId)
.status().in(
Transaction.Status.SETTLED,
Transaction.Status.SETTLING,
Transaction.Status.SUBMITTED_FOR_SETTLEMENT,
Transaction.Status.SETTLEMENT_PENDING
)
);
if (search.getMaximumSize() == 0) {
return CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
}
final Transaction successfulTx = search.getFirst();
return CompletableFuture.completedFuture(
new PayPalChargeSuccessDetails(successfulTx.getGraphQLId()));
}
logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode());
return CompletableFuture.failedFuture(
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
}, executor));
}
private static PaymentStatus getPaymentStatus(Transaction.Status status) {
return switch (status) {
case SETTLEMENT_CONFIRMED, SETTLING, SUBMITTED_FOR_SETTLEMENT, SETTLED -> PaymentStatus.SUCCEEDED;
case AUTHORIZATION_EXPIRED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLEMENT_DECLINED, VOIDED, FAILED ->
PaymentStatus.FAILED;
default -> PaymentStatus.UNKNOWN;
};
}
private static PaymentStatus getPaymentStatus(com.braintree.graphql.client.type.PaymentStatus status) {
try {
Transaction.Status transactionStatus = Transaction.Status.valueOf(status.rawValue);
return getPaymentStatus(transactionStatus);
} catch (final Exception e) {
return PaymentStatus.UNKNOWN;
}
}
private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {
return switch (currency.toLowerCase(Locale.ROOT)) {
// JPY is the only supported zero-decimal currency
case "jpy" -> BigDecimal.valueOf(amount);
default -> BigDecimal.valueOf(amount).scaleByPowerOfTen(-2);
};
}
public record PayPalOneTimePaymentApprovalDetails(String approvalUrl, String paymentId) {
}
public record PayPalChargeSuccessDetails(String paymentId) {
}
}

View File

@@ -10,4 +10,8 @@ public enum PaymentMethod {
* A credit card or debit card, including those from Apple Pay and Google Pay
*/
CARD,
/**
* A PayPal account
*/
PAYPAL,
}

View File

@@ -42,6 +42,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@@ -66,28 +67,18 @@ public class StripeManager implements SubscriptionProcessorManager {
private static final String METADATA_KEY_LEVEL = "level";
// https://stripe.com/docs/currencies?presentment-currency=US
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
"aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd",
"bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf",
"dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd",
"hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd",
"kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr",
"mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln",
"pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std",
"szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst",
"xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw");
private final String apiKey;
private final Executor executor;
private final byte[] idempotencyKeyGenerator;
private final String boostDescription;
private final Set<String> supportedCurrencies;
public StripeManager(
@Nonnull String apiKey,
@Nonnull Executor executor,
@Nonnull byte[] idempotencyKeyGenerator,
@Nonnull String boostDescription) {
@Nonnull String boostDescription,
@Nonnull Set<String> supportedCurrencies) {
this.apiKey = Objects.requireNonNull(apiKey);
if (Strings.isNullOrEmpty(apiKey)) {
throw new IllegalArgumentException("apiKey cannot be empty");
@@ -98,6 +89,7 @@ public class StripeManager implements SubscriptionProcessorManager {
throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty");
}
this.boostDescription = Objects.requireNonNull(boostDescription);
this.supportedCurrencies = supportedCurrencies;
}
@Override
@@ -110,6 +102,11 @@ public class StripeManager implements SubscriptionProcessorManager {
return paymentMethod == PaymentMethod.CARD;
}
@Override
public boolean supportsCurrency(final String currency) {
return supportedCurrencies.contains(currency);
}
private RequestOptions commonOptions() {
return commonOptions(null);
}
@@ -181,7 +178,7 @@ public class StripeManager implements SubscriptionProcessorManager {
@Override
public Set<String> getSupportedCurrencies() {
return SUPPORTED_CURRENCIES;
return supportedCurrencies;
}
/**
@@ -210,10 +207,15 @@ public class StripeManager implements SubscriptionProcessorManager {
}, executor);
}
public CompletableFuture<PaymentIntent> getPaymentIntent(String paymentIntentId) {
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
return CompletableFuture.supplyAsync(() -> {
try {
return PaymentIntent.retrieve(paymentIntentId, commonOptions());
final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, commonOptions());
return new PaymentDetails(paymentIntent.getId(),
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
getPaymentStatusForStatus(paymentIntent.getStatus()),
Instant.ofEpochSecond(paymentIntent.getCreated()));
} catch (StripeException e) {
if (e.getStatusCode() == 404) {
return null;
@@ -224,7 +226,16 @@ public class StripeManager implements SubscriptionProcessorManager {
}, executor);
}
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) {
private static PaymentStatus getPaymentStatusForStatus(String status) {
return switch (status.toLowerCase(Locale.ROOT)) {
case "processing" -> PaymentStatus.PROCESSING;
case "succeeded" -> PaymentStatus.SUCCEEDED;
default -> PaymentStatus.UNKNOWN;
};
}
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level,
long lastSubscriptionCreatedAt) {
return CompletableFuture.supplyAsync(() -> {
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
.setCustomer(customerId)

View File

@@ -16,6 +16,7 @@ public enum SubscriptionProcessor {
// because provider IDs are stored, they should not be reused, and great care
// must be used if a provider is removed from the list
STRIPE(1),
BRAINTREE(2),
;
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();

View File

@@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.subscriptions;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -14,9 +16,27 @@ public interface SubscriptionProcessorManager {
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
boolean supportsCurrency(String currency);
Set<String> getSupportedCurrencies();
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
Set<String> getSupportedCurrencies();
record PaymentDetails(String id,
Map<String, String> customMetadata,
PaymentStatus status,
Instant created) {
}
enum PaymentStatus {
SUCCEEDED,
PROCESSING,
FAILED,
UNKNOWN,
}
}