Continue refactoring
This commit is contained in:
parent
d0f1bbfca7
commit
1411ca6fad
4 changed files with 98 additions and 296 deletions
|
@ -1,7 +1,7 @@
|
|||
import { Container } from "components/Container";
|
||||
import { Spinner } from "components/Spinner";
|
||||
import * as React from "react";
|
||||
import { parseAndHandleRequest } from "services/billingService";
|
||||
import { parseAndHandleRequest } from "services/billing-service";
|
||||
import constants from "utils/strings";
|
||||
|
||||
export default function Home() {
|
||||
|
|
|
@ -1,185 +0,0 @@
|
|||
// TODO: Audit
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/prefer-promise-reject-errors */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/consistent-indexed-object-style */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
|
||||
interface IHTTPHeaders {
|
||||
[headerKey: string]: any;
|
||||
}
|
||||
|
||||
interface IQueryPrams {
|
||||
[paramName: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage all HTTP calls.
|
||||
*/
|
||||
class HTTPService {
|
||||
constructor() {
|
||||
axios.interceptors.response.use(
|
||||
(response) => Promise.resolve(response),
|
||||
(err) => {
|
||||
if (!err.response) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
const { response } = err;
|
||||
return Promise.reject(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* header object to be append to all api calls.
|
||||
*/
|
||||
private headers: IHTTPHeaders = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the headers to the given object.
|
||||
*/
|
||||
public setHeaders(headers: IHTTPHeaders) {
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a header to list of headers.
|
||||
*/
|
||||
public appendHeader(key: string, value: string) {
|
||||
this.headers = {
|
||||
...this.headers,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given header.
|
||||
*/
|
||||
public removeHeader(key: string) {
|
||||
this.headers[key] = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns axios interceptors.
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
public getInterceptors() {
|
||||
return axios.interceptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTTP request.
|
||||
* This is done so that developer can use any functionality
|
||||
* provided by axios. Here, only the set headers are spread
|
||||
* over what was sent in config.
|
||||
*/
|
||||
public async request(config: AxiosRequestConfig, customConfig?: any) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
config.headers = {
|
||||
...this.headers,
|
||||
...config.headers,
|
||||
};
|
||||
if (customConfig?.cancel) {
|
||||
config.cancelToken = new axios.CancelToken(
|
||||
(c) => (customConfig.cancel.exec = c),
|
||||
);
|
||||
}
|
||||
return await axios({ ...config, ...customConfig });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request.
|
||||
*/
|
||||
public get(
|
||||
url: string,
|
||||
params?: IQueryPrams,
|
||||
headers?: IHTTPHeaders,
|
||||
customConfig?: any,
|
||||
) {
|
||||
return this.request(
|
||||
{
|
||||
headers,
|
||||
method: "GET",
|
||||
params,
|
||||
url,
|
||||
},
|
||||
customConfig,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post request
|
||||
*/
|
||||
public post(
|
||||
url: string,
|
||||
data?: any,
|
||||
params?: IQueryPrams,
|
||||
headers?: IHTTPHeaders,
|
||||
customConfig?: any,
|
||||
) {
|
||||
return this.request(
|
||||
{
|
||||
data,
|
||||
headers,
|
||||
method: "POST",
|
||||
params,
|
||||
url,
|
||||
},
|
||||
customConfig,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put request
|
||||
*/
|
||||
public put(
|
||||
url: string,
|
||||
data: any,
|
||||
params?: IQueryPrams,
|
||||
headers?: IHTTPHeaders,
|
||||
customConfig?: any,
|
||||
) {
|
||||
return this.request(
|
||||
{
|
||||
data,
|
||||
headers,
|
||||
method: "PUT",
|
||||
params,
|
||||
url,
|
||||
},
|
||||
customConfig,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete request
|
||||
*/
|
||||
public delete(
|
||||
url: string,
|
||||
data: any,
|
||||
params?: IQueryPrams,
|
||||
headers?: IHTTPHeaders,
|
||||
customConfig?: any,
|
||||
) {
|
||||
return this.request(
|
||||
{
|
||||
data,
|
||||
headers,
|
||||
method: "DELETE",
|
||||
params,
|
||||
url,
|
||||
},
|
||||
customConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a Singleton Service.
|
||||
// This will help me maintain common headers / functionality
|
||||
// at a central place.
|
||||
export default new HTTPService();
|
|
@ -7,7 +7,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import HTTPService from "./HTTPService";
|
||||
|
||||
/**
|
||||
* Communicate with Stripe using their JS SDK, and redirect back to the client
|
||||
|
@ -65,36 +64,20 @@ const isStripeAccountCountry = (c: unknown): c is StripeAccountCountry => {
|
|||
};
|
||||
|
||||
const stripePublishableKey = (accountCountry: StripeAccountCountry) => {
|
||||
if (accountCountry == "IN") {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_STRIPE_IN_PUBLISHABLE_KEY ??
|
||||
"pk_live_51HAhqDK59oeucIMOiTI6MDDM2UWUbCAJXJCGsvjJhiO8nYJz38rQq5T4iyQLDMKxqEDUfU5Hopuj4U5U4dff23oT00fHvZeodC"
|
||||
);
|
||||
} else if (accountCountry == "US") {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY ??
|
||||
"pk_live_51LZ9P4G1ITnQlpAnrP6pcS7NiuJo3SnJ7gibjJlMRatkrd2EY1zlMVTVQG5RkSpLPbsHQzFfnEtgHnk1PiylIFkk00tC0LWXwi"
|
||||
);
|
||||
} else {
|
||||
throw Error("stripe account not found");
|
||||
switch (accountCountry) {
|
||||
case "IN":
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_STRIPE_IN_PUBLISHABLE_KEY ??
|
||||
"pk_live_51HAhqDK59oeucIMOiTI6MDDM2UWUbCAJXJCGsvjJhiO8nYJz38rQq5T4iyQLDMKxqEDUfU5Hopuj4U5U4dff23oT00fHvZeodC"
|
||||
);
|
||||
case "US":
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY ??
|
||||
"pk_live_51LZ9P4G1ITnQlpAnrP6pcS7NiuJo3SnJ7gibjJlMRatkrd2EY1zlMVTVQG5RkSpLPbsHQzFfnEtgHnk1PiylIFkk00tC0LWXwi"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
enum PAYMENT_INTENT_STATUS {
|
||||
SUCCESS = "success",
|
||||
REQUIRE_ACTION = "requires_action",
|
||||
REQUIRE_PAYMENT_METHOD = "requires_payment_method",
|
||||
}
|
||||
|
||||
enum STRIPE_ERROR_TYPE {
|
||||
CARD_ERROR = "card_error",
|
||||
AUTHENTICATION_ERROR = "authentication_error",
|
||||
}
|
||||
|
||||
enum STRIPE_ERROR_CODE {
|
||||
AUTHENTICATION_ERROR = "payment_intent_authentication_failure",
|
||||
}
|
||||
|
||||
type RedirectStatus = "success" | "fail";
|
||||
|
||||
type FailureReason =
|
||||
|
@ -117,13 +100,6 @@ type FailureReason =
|
|||
| "canceled"
|
||||
| "server_error";
|
||||
|
||||
interface SubscriptionUpdateResponse {
|
||||
result: {
|
||||
status: PAYMENT_INTENT_STATUS;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the {@link StripeAccountCountry} for the user */
|
||||
const getUserStripeAccountCountry = async (
|
||||
paymentToken: string,
|
||||
|
@ -212,80 +188,102 @@ export async function updateSubscription(
|
|||
try {
|
||||
const accountCountry = await getUserStripeAccountCountry(paymentToken);
|
||||
const stripe = await getStripe(redirectURL, accountCountry);
|
||||
const { result } = await subscriptionUpdateRequest(
|
||||
const { status, clientSecret } = await updateStripeSubscription(
|
||||
paymentToken,
|
||||
productID,
|
||||
);
|
||||
switch (result.status) {
|
||||
case PAYMENT_INTENT_STATUS.SUCCESS:
|
||||
// subscription updated successfully
|
||||
// no-op required
|
||||
return redirectToApp(redirectURL, RESPONSE_STATUS.success);
|
||||
switch (status) {
|
||||
case "success":
|
||||
// Subscription was updated successfully, nothing more required
|
||||
return redirectToApp(redirectURL, "success");
|
||||
|
||||
case PAYMENT_INTENT_STATUS.REQUIRE_PAYMENT_METHOD:
|
||||
case "requires_payment_method":
|
||||
return redirectToApp(
|
||||
redirectURL,
|
||||
RESPONSE_STATUS.fail,
|
||||
FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
|
||||
"fail",
|
||||
"requires_payment_method",
|
||||
);
|
||||
case PAYMENT_INTENT_STATUS.REQUIRE_ACTION: {
|
||||
const { error } = await stripe.confirmCardPayment(
|
||||
result.clientSecret,
|
||||
);
|
||||
if (error) {
|
||||
logError(
|
||||
error,
|
||||
`${error.message} - subscription update failed`,
|
||||
);
|
||||
if (error.type === STRIPE_ERROR_TYPE.CARD_ERROR) {
|
||||
|
||||
case "requires_action": {
|
||||
const { error } = await stripe.confirmCardPayment(clientSecret);
|
||||
if (!error) {
|
||||
return redirectToApp(redirectURL, "success");
|
||||
} else {
|
||||
console.error("Failed to confirm card payment", error);
|
||||
if (error.type == "card_error") {
|
||||
return redirectToApp(
|
||||
redirectURL,
|
||||
RESPONSE_STATUS.fail,
|
||||
FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
|
||||
"fail",
|
||||
"requires_payment_method",
|
||||
);
|
||||
} else if (
|
||||
error.type === STRIPE_ERROR_TYPE.AUTHENTICATION_ERROR ||
|
||||
error.code === STRIPE_ERROR_CODE.AUTHENTICATION_ERROR
|
||||
error.type == "authentication_error" ||
|
||||
error.code == "payment_intent_authentication_failure"
|
||||
) {
|
||||
return redirectToApp(
|
||||
redirectURL,
|
||||
RESPONSE_STATUS.fail,
|
||||
FAILURE_REASON.AUTHENTICATION_FAILED,
|
||||
"fail",
|
||||
"authentication_failed",
|
||||
);
|
||||
} else {
|
||||
return redirectToApp(redirectURL, RESPONSE_STATUS.fail);
|
||||
return redirectToApp(redirectURL, "fail");
|
||||
}
|
||||
} else {
|
||||
return redirectToApp(redirectURL, RESPONSE_STATUS.success);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, "subscription update failed");
|
||||
redirectToApp(
|
||||
redirectURL,
|
||||
RESPONSE_STATUS.fail,
|
||||
FAILURE_REASON.SERVER_ERROR,
|
||||
);
|
||||
console.log("Subscription update failed", e);
|
||||
redirectToApp(redirectURL, "fail", "server_error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscriptionUpdateRequest(
|
||||
type PaymentStatus = "success" | "requires_action" | "requires_payment_method";
|
||||
|
||||
const isPaymentStatus = (s: unknown): s is PaymentStatus =>
|
||||
s == "success" || s == "requires_action" || s == "requires_payment_method";
|
||||
|
||||
interface UpdateStripeSubscriptionResponse {
|
||||
status: PaymentStatus;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to museum to update an existing Stript subscription with
|
||||
* {@link productID} for the user.
|
||||
*/
|
||||
async function updateStripeSubscription(
|
||||
paymentToken: string,
|
||||
productID: string,
|
||||
): Promise<SubscriptionUpdateResponse> {
|
||||
const response = await HTTPService.post(
|
||||
`${getEndpoint()}/billing/stripe/update-subscription`,
|
||||
{
|
||||
productID,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
): Promise<UpdateStripeSubscriptionResponse> {
|
||||
const url = `${apiHost}/billing/stripe/update-subscription`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Auth-Token": paymentToken,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
body: JSON.stringify({
|
||||
productID,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
||||
const json: unknown = await res.json();
|
||||
if (json && typeof json == "object" && "result" in json) {
|
||||
const result = json.result;
|
||||
if (
|
||||
result &&
|
||||
typeof result == "object" &&
|
||||
"status" in result &&
|
||||
"clientSecret" in result
|
||||
) {
|
||||
const status = result.status;
|
||||
const clientSecret = result.clientSecret;
|
||||
if (isPaymentStatus(status) && typeof clientSecret == "string") {
|
||||
return { status, clientSecret };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected response for ${url}: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
const redirectToApp = (
|
|
@ -1,32 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": "./src",
|
||||
"incremental": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"next.config.js"
|
||||
]
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": "./src",
|
||||
"incremental": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "next.config.js"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue