Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,26 @@ const App = () => {
method: "POST",
});
if (!response.ok) {
dispatch({ type: "SET_STATE", state: { linkToken: null } });
let errorDetail;
try {
const data = await response.json();
errorDetail = data.error || {
error_code: data.error_code || "UNKNOWN",
error_type: data.error_type || "API_ERROR",
error_message:
data.error_message || `Request failed with status ${response.status}`,
};
} catch {
errorDetail = {
error_code: "UNKNOWN",
error_type: "API_ERROR",
error_message: `Request failed with status ${response.status}`,
};
}
dispatch({
type: "SET_STATE",
state: { linkToken: null, linkTokenError: errorDetail },
});
return;
}
const data = await response.json();
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/Components/Error/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react";
import Button from "plaid-threads/Button";
import Note from "plaid-threads/Note";

import InlineLink from "plaid-threads/InlineLink";

import { ErrorDataItem } from "../../dataUtilities";

import styles from "./index.module.scss";
Expand Down Expand Up @@ -68,6 +70,39 @@ const Error = (props: Props) => {
</span>
</div>
</div>
{props.error.error_code === "INVALID_LINK_CUSTOMIZATION" && (
<div className={styles.errorItem}>
<span className={styles.errorMessage}>
<strong>Tip:</strong> In the{" "}
<InlineLink
href="https://dashboard.plaid.com/link/data-transparency-v5"
target="_blank"
>
dashboard under Link &gt; Link Customization Data Transparency
Messaging
</InlineLink>
, ensure at least one use case is selected. After selecting a use
case, make sure to click <strong>Publish Changes</strong>.
</span>
</div>
)}
{props.error.error_code === "INSTITUTION_REGISTRATION_REQUIRED" && (
<div className={styles.errorItem}>
<span className={styles.errorMessage}>
Certain OAuth institutions, including Bank of America, Chase,
Capital One, and American Express, may take up to 24 hours to
become available after obtaining Production access. PNC and Charles
Schwab require a{" "}
<InlineLink
href="https://dashboard.plaid.com/activity/status/oauth-institutions"
target="_blank"
>
special registration process
</InlineLink>{" "}
to access Production data.
</span>
</div>
)}
<Button
small
wide
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/Components/Headers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const Header = () => {
isItemAccess,
backend,
linkTokenError,
linkExitError,
isPaymentInitiation,
} = useContext(Context);

Expand Down Expand Up @@ -78,6 +79,20 @@ const Header = () => {
Error Type: <code>{linkTokenError.error_type}</code>{" "}
</div>
<div>Error Message: {linkTokenError.error_message}</div>
{linkTokenError.error_code === "INVALID_LINK_CUSTOMIZATION" && (
<div>
<strong>Tip:</strong> In the{" "}
<InlineLink
href="https://dashboard.plaid.com/link/data-transparency-v5"
target="_blank"
>
dashboard under Link &gt; Link Customization Data
Transparency Messaging
</InlineLink>
, ensure at least one use case is selected. After selecting a
use case, make sure to click <strong>Publish Changes</strong>.
</div>
)}
</Callout>
) : linkToken === "" ? (
<div className={styles.linkButton}>
Expand All @@ -90,6 +105,62 @@ const Header = () => {
<Link />
</div>
)}
{linkExitError != null && (
<Callout warning>
<div>
Link exited with an error.
</div>
<div>
Error Code: <code>{linkExitError.error_code}</code>
</div>
<div>
Error Type: <code>{linkExitError.error_type}</code>
</div>
<div>Error Message: {linkExitError.error_message}</div>
{linkExitError.display_message && (
<div>Details: {linkExitError.display_message}</div>
)}
{linkExitError.error_code === "INVALID_LINK_CUSTOMIZATION" && (
<div>
<strong>Tip:</strong> In the{" "}
<InlineLink
href="https://dashboard.plaid.com/link/data-transparency-v5"
target="_blank"
>
dashboard under Link &gt; Link Customization Data
Transparency Messaging
</InlineLink>
, ensure at least one use case is selected. After selecting a
use case, make sure to click <strong>Publish Changes</strong>.
</div>
)}
{linkExitError.error_code ===
"INSTITUTION_REGISTRATION_REQUIRED" &&
(["PNC", "Charles Schwab"].some((name) =>
linkExitError.institution_name
?.toLowerCase()
.includes(name.toLowerCase())
) ? (
<div>
{linkExitError.institution_name} requires a special
registration process to access Production data. See{" "}
<InlineLink
href="https://dashboard.plaid.com/activity/status/oauth-institutions"
target="_blank"
>
OAuth institution status
</InlineLink>{" "}
for details.
</div>
) : (
<div>
Certain OAuth institutions, including Bank of America,
Chase, Capital One, and American Express, may take up to 24
hours to become available after obtaining Production access.
</div>
))}
</Callout>
)}
</>
) : (
<>
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/Components/Link/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ const Link = () => {
const { linkToken, isPaymentInitiation, isCraProductsExclusively, dispatch } =
useContext(Context);

const onExit = React.useCallback(
(error: any, metadata: any) => {
if (error != null) {
const linkExitError = {
error_type: error.error_type || "",
error_code: error.error_code || "",
error_message: error.error_message || "",
display_message: error.display_message || "",
institution_name: metadata?.institution?.name || "",
};
dispatch({
type: "SET_STATE",
state: { linkExitError },
});
fetch("/api/link_exit_error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(linkExitError),
});
}
},
[dispatch]
);

const onSuccess = React.useCallback(
(public_token: string) => {
// If the access_token is needed, send public_token to server
Expand Down Expand Up @@ -61,6 +85,7 @@ const Link = () => {
const config: Parameters<typeof usePlaidLink>[0] = {
token: linkToken!,
onSuccess,
onExit,
};

if (window.location.href.includes("?oauth_state_id=")) {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/Context/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ interface QuickstartState {
error_code: string;
error_type: string;
};
linkExitError: {
error_message: string;
error_code: string;
error_type: string;
display_message: string;
institution_name: string;
} | null;
}

const initialState: QuickstartState = {
Expand All @@ -40,6 +47,7 @@ const initialState: QuickstartState = {
error_code: "",
error_message: "",
},
linkExitError: null,
};

type QuickstartAction = {
Expand Down
30 changes: 26 additions & 4 deletions go/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
Expand Down Expand Up @@ -98,6 +99,7 @@ func main() {
// 3. Re-initialize with the link token (from step 1) and the full received redirect URI
// from step 2.

r.POST("/api/link_exit_error", linkExitError)
r.POST("/api/set_access_token", getAccessToken)
r.POST("/api/create_link_token_for_payment", createLinkTokenForPayment)
r.GET("/api/auth", auth)
Expand Down Expand Up @@ -144,13 +146,31 @@ var paymentID string
var authorizationID string
var accountID string

func linkExitError(c *gin.Context) {
var body map[string]interface{}
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
jsonBytes, _ := json.MarshalIndent(body, "", " ")
fmt.Println("[Link Exit Error (frontend)]")
fmt.Println(string(jsonBytes))
c.JSON(http.StatusOK, gin.H{"status": "logged"})
}

func renderError(c *gin.Context, originalErr error) {
if plaidError, err := plaid.ToPlaidError(originalErr); err == nil {
// Return 200 and allow the front end to render the error.
c.JSON(http.StatusOK, gin.H{"error": plaidError})
errorJSON, _ := json.MarshalIndent(plaidError, "", " ")
fmt.Println(string(errorJSON))
statusCode := int(plaidError.GetStatus())
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
c.JSON(statusCode, gin.H{"error": plaidError})
return
}

fmt.Println(originalErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": originalErr.Error()})
}

Expand Down Expand Up @@ -1057,8 +1077,10 @@ func pollWithRetries[T any](requestCallback func() (T, error), ms int, retriesLe
}
response, err := requestCallback()
if err != nil {
plaidErr, err := plaid.ToPlaidError(err)
if plaidErr.ErrorCode != "PRODUCT_NOT_READY" {
plaidErr, parseErr := plaid.ToPlaidError(err)
isProductNotReady := parseErr == nil && plaidErr.ErrorCode == "PRODUCT_NOT_READY"
isServerError := parseErr == nil && plaidErr.HasStatus() && plaidErr.GetStatus() >= 500
if !isProductNotReady && !isServerError {
return zero, err
}
time.Sleep(time.Duration(ms) * time.Millisecond)
Expand Down
22 changes: 22 additions & 0 deletions java/src/main/java/com/plaid/quickstart/PlaidApiException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.plaid.quickstart;

import java.util.Map;

public class PlaidApiException extends RuntimeException {
private final Map<String, Object> errorResponse;
private final int statusCode;

public PlaidApiException(Map<String, Object> errorResponse, int statusCode) {
super(errorResponse.toString());
this.errorResponse = errorResponse;
this.statusCode = statusCode;
}

public Map<String, Object> getErrorResponse() {
return errorResponse;
}

public int getStatusCode() {
return statusCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.plaid.quickstart;

import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;

public class PlaidApiExceptionMapper implements ExceptionMapper<PlaidApiException> {
@Override
public Response toResponse(PlaidApiException exception) {
System.out.println(exception.getErrorResponse());
int statusCode = exception.getStatusCode();
return Response.status(statusCode)
.entity(exception.getErrorResponse())
.type(MediaType.APPLICATION_JSON)
.build();
}
}
38 changes: 38 additions & 0 deletions java/src/main/java/com/plaid/quickstart/PlaidApiHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.plaid.quickstart;

import com.fasterxml.jackson.databind.ObjectMapper;
import retrofit2.Call;
import retrofit2.Response;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class PlaidApiHelper {
private static final ObjectMapper mapper = new ObjectMapper();

@SuppressWarnings("unchecked")
public static <T> T callPlaid(Call<T> call) throws IOException {
Response<T> response = call.execute();
if (!response.isSuccessful()) {
Map<String, Object> error = new HashMap<>();
error.put("status_code", response.code());
try {
String errorBody = response.errorBody().string();
Map<String, Object> body = mapper.readValue(errorBody, Map.class);
error.put("error_code", body.getOrDefault("error_code", "UNKNOWN"));
error.put("error_type", body.getOrDefault("error_type", "API_ERROR"));
error.put("error_message", body.getOrDefault("error_message", errorBody));
error.put("display_message", body.get("display_message"));
} catch (Exception e) {
error.put("error_code", "UNKNOWN");
error.put("error_type", "API_ERROR");
error.put("error_message", "Unknown error (HTTP " + response.code() + ")");
}
Map<String, Object> result = new HashMap<>();
result.put("error", error);
throw new PlaidApiException(result, response.code());
}
return response.body();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.plaid.quickstart.resources.InfoResource;
import com.plaid.quickstart.resources.InvestmentTransactionsResource;
import com.plaid.quickstart.resources.ItemResource;
import com.plaid.quickstart.resources.LinkExitErrorResource;
import com.plaid.quickstart.resources.LinkTokenResource;
import com.plaid.quickstart.resources.LinkTokenWithPaymentResource;
import com.plaid.quickstart.resources.PaymentInitiationResource;
Expand Down Expand Up @@ -107,6 +108,7 @@ public void run(final QuickstartConfiguration configuration,

plaidClient = apiClient.createService(PlaidApi.class);

environment.jersey().register(new PlaidApiExceptionMapper());
environment.jersey().register(new AccessTokenResource(plaidClient, plaidProducts));
environment.jersey().register(new AccountsResource(plaidClient));
environment.jersey().register(new AssetsResource(plaidClient));
Expand All @@ -117,6 +119,7 @@ public void run(final QuickstartConfiguration configuration,
environment.jersey().register(new InfoResource(plaidProducts));
environment.jersey().register(new InvestmentTransactionsResource(plaidClient));
environment.jersey().register(new ItemResource(plaidClient));
environment.jersey().register(new LinkExitErrorResource());
environment.jersey().register(new LinkTokenResource(plaidClient, plaidProducts, countryCodes, redirectUri));
environment.jersey().register(new LinkTokenWithPaymentResource(plaidClient, plaidProducts, countryCodes, redirectUri));
environment.jersey().register(new PaymentInitiationResource(plaidClient));
Expand Down
Loading