From 1411ca6fadbb38685d49935ed7cce97b66b36475 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 3 Apr 2024 19:20:22 +0530 Subject: [PATCH] Continue refactoring --- web/apps/payments/src/pages/index.tsx | 2 +- web/apps/payments/src/services/HTTPService.ts | 185 ------------------ .../{billingService.ts => billing-service.ts} | 158 ++++++++------- web/apps/payments/tsconfig.json | 49 ++--- 4 files changed, 98 insertions(+), 296 deletions(-) delete mode 100644 web/apps/payments/src/services/HTTPService.ts rename web/apps/payments/src/services/{billingService.ts => billing-service.ts} (69%) diff --git a/web/apps/payments/src/pages/index.tsx b/web/apps/payments/src/pages/index.tsx index 96d22d5ad..19151dc0e 100644 --- a/web/apps/payments/src/pages/index.tsx +++ b/web/apps/payments/src/pages/index.tsx @@ -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() { diff --git a/web/apps/payments/src/services/HTTPService.ts b/web/apps/payments/src/services/HTTPService.ts deleted file mode 100644 index 834a18ae6..000000000 --- a/web/apps/payments/src/services/HTTPService.ts +++ /dev/null @@ -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(); diff --git a/web/apps/payments/src/services/billingService.ts b/web/apps/payments/src/services/billing-service.ts similarity index 69% rename from web/apps/payments/src/services/billingService.ts rename to web/apps/payments/src/services/billing-service.ts index 3bb87cd85..63859be57 100644 --- a/web/apps/payments/src/services/billingService.ts +++ b/web/apps/payments/src/services/billing-service.ts @@ -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 { - const response = await HTTPService.post( - `${getEndpoint()}/billing/stripe/update-subscription`, - { - productID, - }, - undefined, - { +): Promise { + 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 = ( diff --git a/web/apps/payments/tsconfig.json b/web/apps/payments/tsconfig.json index 4201fb4a5..f40d4ddd7 100644 --- a/web/apps/payments/tsconfig.json +++ b/web/apps/payments/tsconfig.json @@ -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"] }