Skip to content

Commit

Permalink
Handle errors in magic form (#55)
Browse files Browse the repository at this point in the history
* Handle errors in magic form

* Use support email if provided in branding info

* Expose purchase error as PurchaseError in the public API

* Show loading spinner while polling for purchase status
  • Loading branch information
tonidero authored Feb 6, 2024
1 parent c439885 commit a531263
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 60 deletions.
36 changes: 36 additions & 0 deletions src/entities/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
PurchaseFlowError,
PurchaseFlowErrorCode,
} from "../helpers/purchase-operation-helper";

export enum ErrorCode {
UnknownError = 0,
UserCancelledError = 1,
Expand Down Expand Up @@ -143,6 +148,25 @@ export class ErrorCodeUtils {
return null;
}
}

static convertPurchaseFlowErrorCodeToErrorCode(
code: PurchaseFlowErrorCode,
): ErrorCode {
switch (code) {
case PurchaseFlowErrorCode.ErrorSettingUpPurchase:
return ErrorCode.StoreProblemError;
case PurchaseFlowErrorCode.ErrorChargingPayment:
return ErrorCode.PaymentPendingError;
case PurchaseFlowErrorCode.NetworkError:
return ErrorCode.NetworkError;
case PurchaseFlowErrorCode.MissingEmailError:
return ErrorCode.PurchaseInvalidError;
case PurchaseFlowErrorCode.StripeError:
return ErrorCode.StoreProblemError;
case PurchaseFlowErrorCode.UnknownError:
return ErrorCode.UnknownError;
}
}
}

export enum BackendErrorCode {
Expand Down Expand Up @@ -183,6 +207,18 @@ export class PurchasesError extends Error {
);
}

static getForPurchasesFlowError(
purchasesFlowError: PurchaseFlowError,
): PurchasesError {
return new PurchasesError(
ErrorCodeUtils.convertPurchaseFlowErrorCodeToErrorCode(
purchasesFlowError.errorCode,
),
purchasesFlowError.message,
purchasesFlowError.underlyingErrorMessage,
);
}

constructor(
public readonly errorCode: ErrorCode,
message?: string,
Expand Down
71 changes: 46 additions & 25 deletions src/helpers/purchase-operation-helper.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { ErrorCode, PurchasesError } from "../entities/errors";
import { PurchasesError } from "../entities/errors";
import { Backend } from "../networking/backend";
import { SubscribeResponse } from "../networking/responses/subscribe-response";
import {
CheckoutSessionStatus,
CheckoutStatusError,
CheckoutStatusErrorCodes,
CheckoutStatusResponse,
CheckoutSessionStatus,
} from "../networking/responses/checkout-status-response";

export enum PurchaseFlowErrorCode {
ErrorSettingUpPurchase = 0,
ErrorChargingPayment = 1,
UnknownError = 2,
NetworkError = 3,
StripeError = 4,
MissingEmailError = 5,
}

export class PurchaseFlowError extends Error {
constructor(
public readonly errorCode: PurchaseFlowErrorCode,
message?: string,
public readonly underlyingErrorMessage?: string | null,
) {
super(message);
}
}

export class PurchaseOperationHelper {
private operationSessionId: string | null = null;
private readonly backend: Backend;
Expand Down Expand Up @@ -36,9 +55,9 @@ export class PurchaseOperationHelper {
async pollCurrentPurchaseForCompletion(): Promise<void> {
const operationSessionId = this.operationSessionId;
if (!operationSessionId) {
throw new PurchasesError(
ErrorCode.PurchaseInvalidError,
"Purchase not started before waiting for completion.",
throw new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorSettingUpPurchase,
"No purchase in progress",
);
}

Expand All @@ -47,9 +66,9 @@ export class PurchaseOperationHelper {
if (checkCount > this.maxNumberAttempts) {
this.clearPurchaseInProgress();
reject(
new PurchasesError(
ErrorCode.UnknownError,
"Purchase status was not finished in given timeframe",
new PurchaseFlowError(
PurchaseFlowErrorCode.UnknownError,
"Max attempts reached trying to get successful purchase status",
),
);
return;
Expand Down Expand Up @@ -78,7 +97,12 @@ export class PurchaseOperationHelper {
}
})
.catch((error: PurchasesError) => {
reject(error);
reject(
new PurchaseFlowError(
PurchaseFlowErrorCode.NetworkError,
error.message,
),
);
});
};

Expand All @@ -92,42 +116,39 @@ export class PurchaseOperationHelper {

private handlePaymentError(
error: CheckoutStatusError | undefined | null,
reject: (error: PurchasesError) => void,
reject: (error: PurchaseFlowError) => void,
) {
if (error === null || error === undefined) {
reject(
new PurchasesError(
ErrorCode.UnknownError,
"Purchase failed for unknown reason.",
new PurchaseFlowError(
PurchaseFlowErrorCode.UnknownError,
"Got an error status but error field is empty.",
),
);
return;
}
switch (error.code) {
case CheckoutStatusErrorCodes.SetupIntentCreationFailed:
reject(
new PurchasesError(
ErrorCode.PaymentPendingError,
"Purchase setup intent creation failed",
error.message,
new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorSettingUpPurchase,
"Setup intent creation failed",
),
);
return;
case CheckoutStatusErrorCodes.PaymentMethodCreationFailed:
reject(
new PurchasesError(
ErrorCode.PaymentPendingError,
"Purchase payment method creation failed",
error.message,
new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorSettingUpPurchase,
"Payment method creation failed",
),
);
return;
case CheckoutStatusErrorCodes.PaymentChargeFailed:
reject(
new PurchasesError(
ErrorCode.PaymentPendingError,
"Purchase payment charge failed",
error.message,
new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorChargingPayment,
"Payment charge failed",
),
);
return;
Expand Down
10 changes: 8 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { EntitlementResponse } from "./networking/responses/entitlements-respons
import { RC_ENDPOINT } from "./helpers/constants";
import { Backend } from "./networking/backend";
import { isSandboxApiKey } from "./helpers/api-key-helper";
import { PurchaseOperationHelper } from "./helpers/purchase-operation-helper";
import {
PurchaseFlowError,
PurchaseOperationHelper,
} from "./helpers/purchase-operation-helper";

export type Offerings = InnerOfferings;
export type Offering = InnerOffering;
Expand Down Expand Up @@ -184,7 +187,6 @@ export class Purchases {
rcPackage,
customerEmail,
onFinished: async () => {
await this.purchaseOperationHelper.pollCurrentPurchaseForCompletion();
certainHTMLTarget.innerHTML = "";
// TODO: Add info about transaction in result.
resolve({ customerInfo: await this.getCustomerInfo(appUserId) });
Expand All @@ -193,6 +195,10 @@ export class Purchases {
certainHTMLTarget.innerHTML = "";
reject(new PurchasesError(ErrorCode.UserCancelledError));
},
onError: (e: PurchaseFlowError) => {
certainHTMLTarget.innerHTML = "";
reject(PurchasesError.getForPurchasesFlowError(e));
},
purchases: this,
backend: this.backend,
purchaseOperationHelper: this.purchaseOperationHelper,
Expand Down
1 change: 1 addition & 0 deletions src/networking/responses/branding-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export type BrandingInfoResponse = {
app_icon_webp: string | null;
id: string;
seller_company_name: string | null;
seller_company_support_email?: string | null;
};
55 changes: 39 additions & 16 deletions src/tests/helpers/purchase-operation-helper.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { afterEach, beforeEach, describe, test } from "vitest";
import { PurchaseOperationHelper } from "../../helpers/purchase-operation-helper";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
PurchaseFlowError,
PurchaseFlowErrorCode,
PurchaseOperationHelper,
} from "../../helpers/purchase-operation-helper";
import { Backend } from "../../networking/backend";
import { setupServer, SetupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { StatusCodes } from "http-status-codes";
import { expectPromiseToError } from "../test-helpers";
import { expectPromiseToError, failTest } from "../test-helpers";
import { ErrorCode, PurchasesError } from "../../entities/errors";
import { SubscribeResponse } from "../../networking/responses/subscribe-response";
import {
CheckoutSessionStatus,
CheckoutStatusErrorCodes,
CheckoutStatusResponse,
CheckoutSessionStatus,
} from "../../networking/responses/checkout-status-response";

describe("PurchaseOperationHelper", () => {
Expand Down Expand Up @@ -75,11 +79,11 @@ describe("PurchaseOperationHelper", () => {
});

test("pollCurrentPurchaseForCompletion fails if startPurchase not called before", async () => {
await expectPromiseToError(
await expectPromiseToPurchaseFlowError(
purchaseOperationHelper.pollCurrentPurchaseForCompletion(),
new PurchasesError(
ErrorCode.PurchaseInvalidError,
"Purchase not started before waiting for completion.",
new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorSettingUpPurchase,
"No purchase in progress",
),
);
});
Expand All @@ -99,10 +103,10 @@ describe("PurchaseOperationHelper", () => {
"test-product-id",
"test-email",
);
await expectPromiseToError(
await expectPromiseToPurchaseFlowError(
purchaseOperationHelper.pollCurrentPurchaseForCompletion(),
new PurchasesError(
ErrorCode.UnknownBackendError,
new PurchaseFlowError(
PurchaseFlowErrorCode.NetworkError,
"Unknown backend error. Request: getCheckoutStatus. Status code: 500. Body: null.",
),
);
Expand Down Expand Up @@ -190,13 +194,32 @@ describe("PurchaseOperationHelper", () => {
"test-product-id",
"test-email",
);
await expectPromiseToError(
await expectPromiseToPurchaseFlowError(
purchaseOperationHelper.pollCurrentPurchaseForCompletion(),
new PurchasesError(
ErrorCode.PaymentPendingError,
"Purchase payment charge failed",
"test-error-message",
new PurchaseFlowError(
PurchaseFlowErrorCode.ErrorChargingPayment,
"Payment charge failed",
),
);
});
});

function verifyExpectedError(e: unknown, expectedError: PurchaseFlowError) {
expect(e).toBeInstanceOf(PurchaseFlowError);
const purchasesError = e as PurchaseFlowError;
expect(purchasesError.errorCode).toEqual(expectedError.errorCode);
expect(purchasesError.message).toEqual(expectedError.message);
expect(purchasesError.underlyingErrorMessage).toEqual(
expectedError.underlyingErrorMessage,
);
}

function expectPromiseToPurchaseFlowError(
f: Promise<unknown>,
expectedError: PurchaseFlowError,
) {
return f.then(
() => failTest(),
(e) => verifyExpectedError(e, expectedError),
);
}
13 changes: 13 additions & 0 deletions src/ui/assets/icon-error.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script>
import Icon from "../../assets/error.svg";
</script>

<img src={Icon} alt="icon" class="rcb-ui-asset-icon" />

<style>
img {
width: 7.75rem;
height: 7.75rem;
margin: 0 auto;
}
</style>
Loading

0 comments on commit a531263

Please sign in to comment.