diff --git a/stripe/src/main/java/com/stripe/android/model/PaymentIntent.java b/stripe/src/main/java/com/stripe/android/model/PaymentIntent.java index 3f71d5dcb2d..86fd9220254 100644 --- a/stripe/src/main/java/com/stripe/android/model/PaymentIntent.java +++ b/stripe/src/main/java/com/stripe/android/model/PaymentIntent.java @@ -4,6 +4,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import com.stripe.android.ObjectBuilder; import com.stripe.android.utils.ObjectUtils; import org.json.JSONException; @@ -39,6 +40,7 @@ public final class PaymentIntent extends StripeModel implements StripeIntent { private static final String FIELD_CONFIRMATION_METHOD = "confirmation_method"; private static final String FIELD_CURRENCY = "currency"; private static final String FIELD_DESCRIPTION = "description"; + private static final String FIELD_LAST_PAYMENT_ERROR = "last_payment_error"; private static final String FIELD_LIVEMODE = "livemode"; private static final String FIELD_NEXT_ACTION = "next_action"; private static final String FIELD_PAYMENT_METHOD_TYPES = "payment_method_types"; @@ -67,6 +69,7 @@ public final class PaymentIntent extends StripeModel implements StripeIntent { @Nullable private final String mSource; @Nullable private final Status mStatus; @Nullable private final Usage mSetupFutureUsage; + @Nullable private final Error mLastPaymentError; @Nullable @Override @@ -208,6 +211,14 @@ public Status getStatus() { return mStatus; } + /** + * @return The payment error encountered in the previous PaymentIntent confirmation. + */ + @Nullable + public Error getLastPaymentError() { + return mLastPaymentError; + } + private PaymentIntent( @Nullable String id, @Nullable String objectType, @@ -225,7 +236,8 @@ private PaymentIntent( @Nullable String receiptEmail, @Nullable String source, @Nullable Status status, - @Nullable Usage setupFutureUsage) { + @Nullable Usage setupFutureUsage, + @Nullable Error lastPaymentError) { mId = id; mObjectType = objectType; mPaymentMethodTypes = paymentMethodTypes; @@ -245,6 +257,7 @@ private PaymentIntent( mSetupFutureUsage = setupFutureUsage; mNextActionType = mNextAction != null ? NextActionType.fromCode((String) mNextAction.get(FIELD_NEXT_ACTION_TYPE)) : null; + mLastPaymentError = lastPaymentError; } @NonNull @@ -287,6 +300,8 @@ public static PaymentIntent fromJson(@Nullable JSONObject jsonObject) { Usage.fromCode(optString(jsonObject, FIELD_SETUP_FUTURE_USAGE)); final Map nextAction = optMap(jsonObject, FIELD_NEXT_ACTION); final String source = optString(jsonObject, FIELD_SOURCE); + final Error lastPaymentError = + Error.fromJson(jsonObject.optJSONObject(FIELD_LAST_PAYMENT_ERROR)); return new PaymentIntent( id, @@ -305,7 +320,9 @@ public static PaymentIntent fromJson(@Nullable JSONObject jsonObject) { receiptEmail, source, status, - setupFutureUsage); + setupFutureUsage, + lastPaymentError + ); } @Override @@ -331,7 +348,8 @@ private boolean typedEquals(@NonNull PaymentIntent paymentIntent) { && ObjectUtils.equals(mSetupFutureUsage, paymentIntent.mSetupFutureUsage) && ObjectUtils.equals(mPaymentMethodTypes, paymentIntent.mPaymentMethodTypes) && ObjectUtils.equals(mNextAction, paymentIntent.mNextAction) - && ObjectUtils.equals(mNextActionType, paymentIntent.mNextActionType); + && ObjectUtils.equals(mNextActionType, paymentIntent.mNextActionType) + && ObjectUtils.equals(mLastPaymentError, paymentIntent.mLastPaymentError); } @Override @@ -339,7 +357,212 @@ public int hashCode() { return ObjectUtils.hash(mId, mObjectType, mAmount, mCanceledAt, mCaptureMethod, mClientSecret, mConfirmationMethod, mCreated, mCurrency, mDescription, mLiveMode, mReceiptEmail, mSource, mStatus, mPaymentMethodTypes, mNextAction, - mNextActionType, mSetupFutureUsage); + mNextActionType, mSetupFutureUsage, mLastPaymentError); } + /** + * The payment error encountered in the previous PaymentIntent confirmation. + * + * See last_payment_error. + */ + public static final class Error { + private static final String FIELD_CHARGE = "charge"; + private static final String FIELD_CODE = "code"; + private static final String FIELD_DECLINE_CODE = "decline_code"; + private static final String FIELD_DOC_URL = "doc_url"; + private static final String FIELD_MESSAGE = "message"; + private static final String FIELD_PARAM = "param"; + private static final String FIELD_PAYMENT_METHOD = "payment_method"; + private static final String FIELD_TYPE = "type"; + + /** + * For card errors, the ID of the failed charge. + */ + @Nullable public final String charge; + + /** + * For some errors that could be handled programmatically, a short string indicating the + * error code reported. + */ + @Nullable public final String code; + + /** + * For card errors resulting from a card issuer decline, a short string indicating the + * card issuer’s reason for the decline + * if they provide one. + */ + @Nullable public final String declineCode; + + /** + * A URL to more information about the + * error code reported. + */ + @Nullable public final String docUrl; + + /** + * A human-readable message providing more details about the error. For card errors, + * these messages can be shown to your users. + */ + @Nullable public final String message; + + /** + * If the error is parameter-specific, the parameter related to the error. + * For example, you can use this to display a message near the correct form field. + */ + @Nullable public final String param; + + /** + * The PaymentMethod object for errors returned on a request involving a PaymentMethod. + */ + @Nullable public final PaymentMethod paymentMethod; + + /** + * The type of error returned. + */ + @Nullable public final Type type; + + private Error(@NonNull Builder builder) { + charge = builder.mCharge; + code = builder.mCode; + declineCode = builder.mDeclineCode; + docUrl = builder.mDocUrl; + message = builder.mMessage; + param = builder.mParam; + paymentMethod = builder.mPaymentMethod; + type = builder.mType; + } + + @Nullable + private static Error fromJson(@Nullable JSONObject errorJson) { + if (errorJson == null) { + return null; + } + + return new Builder() + .setCharge(StripeJsonUtils.optString(errorJson, FIELD_CHARGE)) + .setCode(StripeJsonUtils.optString(errorJson, FIELD_CODE)) + .setDeclineCode(StripeJsonUtils.optString(errorJson, FIELD_DECLINE_CODE)) + .setDocUrl(StripeJsonUtils.optString(errorJson, FIELD_DOC_URL)) + .setMessage(StripeJsonUtils.optString(errorJson, FIELD_MESSAGE)) + .setParam(StripeJsonUtils.optString(errorJson, FIELD_PARAM)) + .setPaymentMethod( + PaymentMethod.fromJson(errorJson.optJSONObject(FIELD_PAYMENT_METHOD))) + .setType(Type.fromCode(StripeJsonUtils.optString(errorJson, FIELD_TYPE))) + .build(); + } + + @Override + public int hashCode() { + return ObjectUtils.hash(charge, code, declineCode, docUrl, message, param, + paymentMethod, type); + } + + @Override + public boolean equals(@Nullable Object obj) { + return super.equals(obj) || (obj instanceof Error && typedEquals((Error) obj)); + } + + private boolean typedEquals(@NonNull Error error) { + return ObjectUtils.equals(charge, error.charge) && + ObjectUtils.equals(code, error.code) && + ObjectUtils.equals(declineCode, error.declineCode) && + ObjectUtils.equals(docUrl, error.docUrl) && + ObjectUtils.equals(message, error.message) && + ObjectUtils.equals(param, error.param) && + ObjectUtils.equals(paymentMethod, error.paymentMethod) && + ObjectUtils.equals(type, error.type); + } + + private static final class Builder implements ObjectBuilder { + @Nullable private String mCharge; + @Nullable private String mCode; + @Nullable private String mDeclineCode; + @Nullable private String mDocUrl; + @Nullable private String mMessage; + @Nullable private String mParam; + @Nullable private PaymentMethod mPaymentMethod; + @Nullable private Type mType; + + @NonNull + private Builder setCharge(@Nullable String charge) { + this.mCharge = charge; + return this; + } + + @NonNull + private Builder setCode(@Nullable String code) { + this.mCode = code; + return this; + } + + @NonNull + private Builder setDeclineCode(@Nullable String declineCode) { + this.mDeclineCode = declineCode; + return this; + } + + @NonNull + private Builder setDocUrl(@Nullable String docUrl) { + this.mDocUrl = docUrl; + return this; + } + + @NonNull + private Builder setMessage(@Nullable String message) { + this.mMessage = message; + return this; + } + + @NonNull + private Builder setParam(@Nullable String mParam) { + this.mParam = mParam; + return this; + } + + @NonNull + private Builder setPaymentMethod(@Nullable PaymentMethod paymentMethod) { + this.mPaymentMethod = paymentMethod; + return this; + } + + @NonNull + private Builder setType(@Nullable Type type) { + this.mType = type; + return this; + } + + @NonNull + @Override + public Error build() { + return new Error(this); + } + } + + public enum Type { + ApiConnectionError("api_connection_error"), + ApiError("api_error"), + AuthenticationError("authentication_error"), + CardError("card_error"), + IdempotencyError("idempotency_error"), + InvalidRequestError("invalid_request_error"), + RateLimitError("rate_limit_error"); + + @NonNull public final String code; + + Type(@NonNull String code) { + this.code = code; + } + + @Nullable + private static Type fromCode(@Nullable String typeCode) { + for (Type type : values()) { + if (type.code.equals(typeCode)) { + return type; + } + } + + return null; + } + } + } } diff --git a/stripe/src/test/java/com/stripe/android/model/PaymentIntentFixtures.java b/stripe/src/test/java/com/stripe/android/model/PaymentIntentFixtures.java index a6ae0261c00..fece0b0c01f 100644 --- a/stripe/src/test/java/com/stripe/android/model/PaymentIntentFixtures.java +++ b/stripe/src/test/java/com/stripe/android/model/PaymentIntentFixtures.java @@ -263,6 +263,78 @@ public final class PaymentIntentFixtures { "}" )); + public static final PaymentIntent PI_WITH_LAST_PAYMENT_ERROR = + Objects.requireNonNull(PaymentIntent.fromString("{\n" + + "\t\"id\": \"pi_1F7J1aCRMbs6FrXfaJcvbxF6\",\n" + + "\t\"object\": \"payment_intent\",\n" + + "\t\"amount\": 1000,\n" + + "\t\"canceled_at\": null,\n" + + "\t\"cancellation_reason\": null,\n" + + "\t\"capture_method\": \"manual\",\n" + + "\t\"client_secret\": \"pi_1F7J1aCRMbs6FrXfaJcvbxF6_secret_mIuDLsSfoo1m6s\",\n" + + "\t\"confirmation_method\": \"automatic\",\n" + + "\t\"created\": 1565775850,\n" + + "\t\"currency\": \"usd\",\n" + + "\t\"description\": \"Example PaymentIntent\",\n" + + "\t\"last_payment_error\": {\n" + + "\t\t\"code\": \"payment_intent_authentication_failure\",\n" + + "\t\t\"doc_url\": \"https://stripe.com/docs/error-codes/payment-intent-authentication-failure\",\n" + + "\t\t\"message\": \"The provided PaymentMethod has failed authentication. You can provide payment_method_data or a new PaymentMethod to attempt to fulfill this PaymentIntent again.\",\n" + + "\t\t\"payment_method\": {\n" + + "\t\t\t\"id\": \"pm_1F7J1bCRMbs6FrXfQKsYwO3U\",\n" + + "\t\t\t\"object\": \"payment_method\",\n" + + "\t\t\t\"billing_details\": {\n" + + "\t\t\t\t\"address\": {\n" + + "\t\t\t\t\t\"city\": null,\n" + + "\t\t\t\t\t\"country\": null,\n" + + "\t\t\t\t\t\"line1\": null,\n" + + "\t\t\t\t\t\"line2\": null,\n" + + "\t\t\t\t\t\"postal_code\": null,\n" + + "\t\t\t\t\t\"state\": null\n" + + "\t\t\t\t},\n" + + "\t\t\t\t\"email\": null,\n" + + "\t\t\t\t\"name\": null,\n" + + "\t\t\t\t\"phone\": null\n" + + "\t\t\t},\n" + + "\t\t\t\"card\": {\n" + + "\t\t\t\t\"brand\": \"visa\",\n" + + "\t\t\t\t\"checks\": {\n" + + "\t\t\t\t\t\"address_line1_check\": null,\n" + + "\t\t\t\t\t\"address_postal_code_check\": null,\n" + + "\t\t\t\t\t\"cvc_check\": null\n" + + "\t\t\t\t},\n" + + "\t\t\t\t\"country\": null,\n" + + "\t\t\t\t\"exp_month\": 8,\n" + + "\t\t\t\t\"exp_year\": 2020,\n" + + "\t\t\t\t\"funding\": \"credit\",\n" + + "\t\t\t\t\"generated_from\": null,\n" + + "\t\t\t\t\"last4\": \"3220\",\n" + + "\t\t\t\t\"three_d_secure_usage\": {\n" + + "\t\t\t\t\t\"supported\": true\n" + + "\t\t\t\t},\n" + + "\t\t\t\t\"wallet\": null\n" + + "\t\t\t},\n" + + "\t\t\t\"created\": 1565775851,\n" + + "\t\t\t\"customer\": null,\n" + + "\t\t\t\"livemode\": false,\n" + + "\t\t\t\"metadata\": {},\n" + + "\t\t\t\"type\": \"card\"\n" + + "\t\t},\n" + + "\t\t\"type\": \"invalid_request_error\"\n" + + "\t},\n" + + "\t\"livemode\": false,\n" + + "\t\"next_action\": null,\n" + + "\t\"payment_method\": null,\n" + + "\t\"payment_method_types\": [\n" + + "\t\t\"card\"\n" + + "\t],\n" + + "\t\"receipt_email\": null,\n" + + "\t\"setup_future_usage\": null,\n" + + "\t\"shipping\": null,\n" + + "\t\"source\": null,\n" + + "\t\"status\": \"requires_payment_method\"\n" + + "}")); + public static final PaymentIntent.RedirectData REDIRECT_DATA = new PaymentIntent.RedirectData("https://example.com", "yourapp://post-authentication-return-url"); diff --git a/stripe/src/test/java/com/stripe/android/model/PaymentIntentTest.java b/stripe/src/test/java/com/stripe/android/model/PaymentIntentTest.java index 342e68e8707..3a4f6a9f01e 100644 --- a/stripe/src/test/java/com/stripe/android/model/PaymentIntentTest.java +++ b/stripe/src/test/java/com/stripe/android/model/PaymentIntentTest.java @@ -225,4 +225,23 @@ public void getNextActionTypeAndStripeSdkData_whenRedirectToUrl() { PaymentIntentFixtures.PI_REQUIRES_REDIRECT.getNextActionType()); assertNull(PaymentIntentFixtures.PI_REQUIRES_REDIRECT.getStripeSdkData()); } + + @Test + public void getLastPaymentError_parsesCorrectly() { + final PaymentIntent.Error lastPaymentError = + PaymentIntentFixtures.PI_WITH_LAST_PAYMENT_ERROR.getLastPaymentError(); + assertNotNull(lastPaymentError); + assertNotNull(lastPaymentError.paymentMethod); + assertEquals("pm_1F7J1bCRMbs6FrXfQKsYwO3U", lastPaymentError.paymentMethod.id); + assertEquals("payment_intent_authentication_failure", lastPaymentError.code); + assertEquals(PaymentIntent.Error.Type.InvalidRequestError, lastPaymentError.type); + assertEquals( + "https://stripe.com/docs/error-codes/payment-intent-authentication-failure", + lastPaymentError.docUrl + ); + assertEquals( + "The provided PaymentMethod has failed authentication. You can provide payment_method_data or a new PaymentMethod to attempt to fulfill this PaymentIntent again.", + lastPaymentError.message + ); + } }