Created
August 11, 2025 16:46
-
-
Save 50-Course/205c13c5361d7b28bf21902c1048182e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export class AutomationService { | |
| private readonly logger = new Logger(AutomationService.name); | |
| constructor( | |
| @InjectRepository(User) | |
| private userRepository: Repository<User>, | |
| @InjectRepository(CardLog) | |
| private cardLogRepository: Repository<CardLog>, | |
| ) {} | |
| async addCardToParamount( | |
| input: CardAutomationInput, | |
| ): Promise<AutomationResponse> { | |
| let browser; | |
| let page; | |
| try { | |
| const user = await this.userRepository.findOne({ | |
| where: { id: input.userId }, | |
| }); | |
| if (!user) { | |
| throw new NotFoundException("User not found"); | |
| } | |
| if (!user.paramountEmail || !user.paramountPassword) { | |
| throw new Error("User does not have Paramount+ credentials"); | |
| } | |
| this.logger.log(`Starting card automation for user: ${user.email}`); | |
| browser = await puppeteer.launch({ | |
| headless: false, | |
| args: ["--no-sandbox", "--disable-setuid-sandbox"], | |
| }); | |
| page = await browser.newPage(); | |
| await page.setViewport({ width: 1366, height: 768 }); | |
| // here we choose to navigate to Paramount+ sign-in page directly because we know the URL | |
| await this.retryAction(async () => { | |
| await page.goto("https://www.paramountplus.com/account/signin/", { | |
| waitUntil: "networkidle2", | |
| }); | |
| }, "SIGN IN"); | |
| await this.retryAction(async () => { | |
| await page.waitForSelector('input[type="email"]', { timeout: 50000 }); | |
| await page.type('input[type="email"]', user.paramountEmail); | |
| await page.type('input[type="password"]', user.paramountPassword); | |
| await page.click("button.qt-continuebtn"); | |
| // at signup, if vpn is not on, or location is not US, we hit some errors, we listen to that error | |
| // and rethrow it, | |
| // | |
| // we could otherwise, display a structured format of this error in our API layer (the graphQL response as an ErrorResponse) | |
| try { | |
| const errorSelector = "p.form-message.error"; | |
| await page.waitForSelector(errorSelector, { timeout: 50000 }); | |
| const errorElement = await page.$(errorSelector); | |
| const errorMessage = await page.evaluate( | |
| (el) => el.textContent.trim(), | |
| errorElement, | |
| ); | |
| throw new Error(`Login failed: ${errorMessage}`); | |
| } catch (e) { | |
| if (e.name !== "TimeoutError") { | |
| throw e; | |
| } | |
| } | |
| }, "Login"); | |
| // upon login, we're redirected to a paywalled page, and attempt to select a payment plan from the payment options plans | |
| await this.retryAction(async () => { | |
| await page.waitForNavigation({ waitUntil: "networkidle2" }); | |
| }, "Wait for login and redirect to plan selection"); | |
| // as a decision, we select the cheaper payment option plan, our users' can decide to upgrade in future | |
| await this.retryAction(async () => { | |
| await page.waitForSelector("button.qt-continuebtn:nth-of-type(2)", { | |
| timeout: 10000, | |
| }); | |
| await page.click("button.qt-continuebtn:nth-of-type(2)"); | |
| }, "Select the cheaper plan option"); | |
| await this.retryAction(async () => { | |
| await page.waitForNavigation({ waitUntil: "networkidle2" }); | |
| }, "Wait for redirect to payment page"); | |
| // finally, we would fill in the user's payment credentials, submit and log thi payment entry | |
| await this.retryAction(async () => { | |
| // since these are not regular HTML elements, we can't work with the DOM directly, they are hosted fields | |
| // therefore, we hve to : | |
| // | |
| // To interact with these fields, you need to: | |
| // | |
| // Find the <iframe> element for each hosted field. | |
| // | |
| // Get the frame object for that <iframe> from Puppeteer. | |
| // | |
| // Use the frame object to find and type into the fields inside the iframe. | |
| // Find the iframe for the credit card number | |
| // const cardNumberFrame = page | |
| // .frames() | |
| // .find( | |
| // (frame) => | |
| // frame.url().includes("api.recurly.com/js/v1/field.html") && | |
| // frame.url().includes("type%22%3A%22number"), | |
| // ); | |
| // if (!cardNumberFrame) | |
| // throw new Error("Credit card number iframe not found."); | |
| // | |
| // // Find the iframe for the expiration month | |
| // const expiryMonthFrame = page | |
| // .frames() | |
| // .find( | |
| // (frame) => | |
| // frame.url().includes("api.recurly.com/js/v1/field.html") && | |
| // frame.url().includes("type%22%3A%22month"), | |
| // ); | |
| // if (!expiryMonthFrame) | |
| // throw new Error("Expiration month iframe not found."); | |
| // | |
| // // Find the iframe for the expiration year | |
| // const expiryYearFrame = page | |
| // .frames() | |
| // .find( | |
| // (frame) => | |
| // frame.url().includes("api.recurly.com/js/v1/field.html") && | |
| // frame.url().includes("type%22%3A%22year"), | |
| // ); | |
| // if (!expiryYearFrame) | |
| // throw new Error("Expiration year iframe not found."); | |
| // | |
| // // Find the iframe for the CVV | |
| // const cvvFrame = page | |
| // .frames() | |
| // .find( | |
| // (frame) => | |
| // frame.url().includes("api.recurly.com/js/v1/field.html") && | |
| // frame.url().includes("type%22%3A%22cvv"), | |
| // ); | |
| // if (!cvvFrame) throw new Error("CVV iframe not found."); | |
| // Use the below approach, much cleaner | |
| // we wait for all hoisted fields to be present | |
| const [cardNumberFrame, expiryMonthFrame, expiryYearFrame, cvvFrame] = | |
| await Promise.all([ | |
| page | |
| .waitForSelector("#cc-number iframe") | |
| .then((handle) => handle.contentFrame()), | |
| page | |
| .waitForSelector("#cc-expire-month iframe") | |
| .then((handle) => handle.contentFrame()), | |
| page | |
| .waitForSelector("#cc-expire-year iframe") | |
| .then((handle) => handle.contentFrame()), | |
| page | |
| .waitForSelector("#cc-cvv iframe") | |
| .then((handle) => handle.contentFrame()), | |
| ]); | |
| if ( | |
| !cardNumberFrame || | |
| !expiryMonthFrame || | |
| !expiryYearFrame || | |
| !cvvFrame | |
| ) { | |
| throw new Error("One or more payment iframes were not found."); | |
| } | |
| await cardNumberFrame.type("input", input.cardNumber); | |
| await expiryMonthFrame.type("input", input.expiryMonth); | |
| await expiryYearFrame.type("input", input.expiryYear); | |
| await cvvFrame.type("input", input.cvv); | |
| // for standard DOM elements, we simply pass in their values | |
| // pass in the first and last name fields | |
| if (input.cardholderName) { | |
| const names = input.cardholderName.split(" "); | |
| if (names.length > 0) { | |
| await page.type('input[data-ci="first_name"]', names[0]); | |
| } | |
| if (names.length > 1) { | |
| await page.type( | |
| 'input[data-ci="last_name"]', | |
| names.slice(1).join(" "), | |
| ); | |
| } | |
| } | |
| if (input.address) { | |
| await page.type('input[data-ci="address1"]', input.address); | |
| } | |
| if (input.city) { | |
| await page.type('input[data-ci="city"]', input.city); | |
| } | |
| if (input.state) { | |
| await page.select('select[data-recurly="state"]', input.state); | |
| } | |
| if (input.zipCode) { | |
| await page.type('input[data-ci="postal_code"]', input.zipCode); | |
| } | |
| // finally we click on the Submit button to complete our payment intiation operation | |
| const submitButtonSelector = "button.button.primary"; | |
| await page.waitForSelector(submitButtonSelector, { timeout: 10000 }); | |
| await page.click(submitButtonSelector); | |
| }, "Fill card details"); | |
| // NOTE: we are not sure, this would redirect here, so no point in having this! | |
| // await this.retryAction(async () => { | |
| // await page.waitForSelector( | |
| // '.success, .alert-success, [data-testid="success"]', | |
| // { timeout: 15000 }, | |
| // ); | |
| // }, "Wait for success confirmation"); | |
| // To round up, we log the operation within our system, and return an API response to through our graphQL server | |
| await this.logCardOperation( | |
| input.userId, | |
| "ADD_CARD", | |
| "SUCCESS", | |
| undefined, | |
| input.cardNumber.slice(-4), | |
| ); | |
| this.logger.log( | |
| `Card automation completed successfully for user: ${user.email}`, | |
| ); | |
| return { | |
| success: true, | |
| message: "Card added successfully to Paramount+", | |
| }; | |
| } catch (error) { | |
| // at this point, we can assume our operation is failing, we: | |
| // log the error for tracing, and debugging | |
| // | |
| // and return a structured response back to our user | |
| this.logger.error( | |
| `Card automation failed: ${error.message}`, | |
| error.stack, | |
| ); | |
| await this.logCardOperation( | |
| input.userId, | |
| "ADD_CARD", | |
| "FAILED", | |
| error.message, | |
| input.cardNumber.slice(-4), | |
| ); | |
| return { | |
| success: false, | |
| errorMessage: error.message, | |
| }; | |
| } finally { | |
| if (page) await page.close(); | |
| if (browser) await browser.close(); | |
| } | |
| } | |
| // leave every other stuffs the same | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment