diff --git a/.dockerignore b/.dockerignore index e69de29..fdc85fa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -0,0 +1,4 @@ +docs/ +.github/ +.claude/ +.wrangler/ \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 108cfe4..ab80835 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: - lint - test runs-on: ubuntu-22.04 - name: Release + name: release-container permissions: packages: write contents: read diff --git a/.gitignore b/.gitignore index cdb3e2e..e96e106 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,7 @@ ynab_connect # docs docs/.vitepress/dist -docs/.vitepress/cache \ No newline at end of file +docs/.vitepress/cache + +# Claude Code +.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f491524 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# Claude Instructions + +## Tech Stack + +This project uses Bun. + +## Useful commands + +This projects uses Biome for linting and formatting. You can run it with: + +```bash +bun lint +``` + +and fix with: + +```bash +bun lint:fix +``` + +Prefer fixing with Biome over manually fixing linting errors. + +Run type checks with + +```bash +bun types +``` + +## Good Practice + +When installing new packages use the `-E` flag to pin the version of the package in `bun.lockb`. + +Before implementing a new feature, see if there is an existing implementation in the codebase that you can reuse or extend. + +## Testing + +Always write unit tests for new features. This projects uses Bun testing. It's very similar to Jest and Vitest. You must import it, describe, etc. from 'bun:test'. + +Avoid mocking dependencies, except in cases where the dependency is external (e.g., network requests, database calls). + +In cases where you need to mock something external, see if you can mock the network request or database call directly instead of mocking the entire dependency. + +## Working with the engineer + +Before implementing a new feature, discuss it with the engineer to ensure alignment on the approach and design. + +Come up with a plan. + +## Coding style + +Avoid being too clever. Write code that is easy to read and understand. + +E.g. avoid double ternaries, complex one-liners, etc. + +Prefer typing out things instead of using advanced TypeScript features that make the code harder to read. + +# Writing documentation +This project uses Vitepress for documentation. + +When writing documentation, follow the existing style and structure in the docs folder. + +Guides go in the `docs/guide` folder. + +Connectors go in the `docs/connectors` folder. + +Write in a concise and clear manner. Do not use emojis. Do not over explain, but when needed create a guide to explain a concept. + +When adding a new guide or connector, update the sidebar in `docs/.vitepress/config.ts` to include the new documentation. \ No newline at end of file diff --git a/biome.json b/biome.json index 29e7cce..047cd18 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["src"] + "includes": ["src/**/*.ts"] }, "formatter": { "enabled": true, diff --git a/bun.lockb b/bun.lockb index 332e7e5..891680d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1dd2ed8..732e618 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,5 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; import { defineConfig } from "vitepress"; +// Helper function to extract title from markdown frontmatter +function extractTitle(filePath: string): string { + const content = fs.readFileSync(filePath, "utf-8"); + const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + + if (frontmatterMatch) { + const titleMatch = frontmatterMatch[1].match(/title:\s*(.+?)(?:\r?\n|$)/); + if (titleMatch) { + return titleMatch[1].trim().replace(/['"]/g, ""); + } + } + + // Fallback to filename + return path + .basename(filePath, ".md") + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +// Generate sidebar items from directory +function generateSidebarItems(dir: string, urlPrefix: string) { + const fullPath = path.join(__dirname, "..", dir); + + if (!fs.existsSync(fullPath)) { + return []; + } + + return fs + .readdirSync(fullPath) + .filter((file) => file.endsWith(".md")) + .map((file) => { + const filePath = path.join(fullPath, file); + const title = extractTitle(filePath); + const link = `/${urlPrefix}/${path.basename(file, ".md")}`; + return { text: title, link }; + }) + .sort((a, b) => a.text.localeCompare(b.text)); +} + // https://vitepress.dev/reference/site-config export default defineConfig({ title: "ynab-connect", @@ -19,16 +61,17 @@ export default defineConfig({ { text: "Configuration", link: "/configuration" }, ], }, + { + text: "Guides", + items: generateSidebarItems("guide", "guide"), + }, { text: "Features", items: [{ text: "Browser", link: "/browser" }], }, { text: "Connectors", - items: [ - { text: "Trading 212", link: "/connectors/trading-212" }, - { text: "UK Student Loan", link: "/connectors/uk-student-loan" }, - ], + items: generateSidebarItems("connectors", "connectors"), }, ], diff --git a/docs/connectors/standard-life-pension.md b/docs/connectors/standard-life-pension.md new file mode 100644 index 0000000..93f545f --- /dev/null +++ b/docs/connectors/standard-life-pension.md @@ -0,0 +1,36 @@ +--- +title: Standard Life Pension +--- +# Standard Life Pension + +Sync your Standard Life UK pension balance. + +This connector uses a [headless browser](/browser) to log in to your account and retrieve your pension balance. + +This connector is only available for UK accounts. + +## Two-factor authentication + +Standard Life requires two-factor authentication via SMS. You will need to set up SMS forwarding to automatically provide the code. + +See the [SMS forwarding guide](/guide/sms-forwarding) for instructions on how to set this up. + +## Finding your policy number + +To find your policy number: + +1. Log in to [Standard Life online](https://online.standardlife.com/secure/customer-authentication-client/customer/login) +2. Navigate to your pension dashboard +3. Your policy number will be displayed on your pension plan details + +## Sample configuration + +```yaml +- name: "Standard Life Pension" + type: "standard_life_pension" + interval: "0 0 * * *" + ynabAccountId: "YOUR_YNAB_ACCOUNT_ID" + username: "YOUR_STANDARD_LIFE_USERNAME" + password: "YOUR_STANDARD_LIFE_PASSWORD" + policyNumber: "YOUR_POLICY_NUMBER" +``` diff --git a/docs/connectors/uk-student-loan.md b/docs/connectors/uk-student-loan.md index 490b590..61195c4 100644 --- a/docs/connectors/uk-student-loan.md +++ b/docs/connectors/uk-student-loan.md @@ -1,5 +1,5 @@ --- -title: Bruh +title: UK Student Loan --- # UK Student Loan Sync your UK Student Loan balance from the Student Loans Company website. diff --git a/docs/guide/sms-forwarding.md b/docs/guide/sms-forwarding.md new file mode 100644 index 0000000..dc905e8 --- /dev/null +++ b/docs/guide/sms-forwarding.md @@ -0,0 +1,26 @@ +--- +title: SMS Forwarding for 2FA +--- +# SMS Forwarding for 2FA + +Some connectors require two-factor authentication via SMS. To enable automatic authentication, you need to set up SMS forwarding. + +## Overview + +The SMS forwarding setup allows ynab-connect to receive 2FA codes sent to your phone automatically. When a connector requires a 2FA code, it will wait for the code to be forwarded to the application. + +## Requirements + +- A smartphone (iOS or Android) +- Access to SMS messages +- A method to forward SMS to the ynab-connect server + +## Setup Instructions + +Documentation for setting up SMS forwarding will be added here. + +## Supported Connectors + +The following connectors support 2FA via SMS: + +- [Standard Life Pension](/connectors/standard-life-pension) diff --git a/lefthook.yml b/lefthook.yml index ded85a9..72fce2f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,7 +2,4 @@ pre-commit: parallel: true jobs: - run: bun run lint - glob: "*.ts" - - - run: bun test glob: "*.ts" \ No newline at end of file diff --git a/package.json b/package.json index 4dd65c2..5c79544 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "lint": "biome check", "lint:fix": "biome check --write", + "types": "tsc --noEmit", "build:binary": "bun build src/index.ts --compile --minify --sourcemaps --outfile ynab-connect", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", @@ -25,7 +26,7 @@ "dependencies": { "node-cron": "4.2.1", "pino": "10.0.0", - "puppeteer-core": "24.24.0", + "puppeteer": "24.25.0", "retry": "0.13.1", "yaml": "2.8.1", "ynab": "2.10.0", diff --git a/src/2fa.test.ts b/src/2fa.test.ts new file mode 100644 index 0000000..e82e609 --- /dev/null +++ b/src/2fa.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { await2FACode, start2FAServer, stop2FAServer } from "./2fa.ts"; + +describe("2FA Module", () => { + const TEST_PORT = 4031; + + beforeEach(() => { + start2FAServer(TEST_PORT); + }); + + afterEach(() => { + stop2FAServer(); + }); + + describe("Pattern Matching", () => { + it("should capture a 6-digit code", async () => { + const codePromise = await2FACode("generic-6digit", 5000); + + const response = await fetch( + `http://localhost:${TEST_PORT}/capture-2fa`, + { + method: "POST", + body: "Your verification code: 123456", + }, + ); + + expect(response.status).toBe(200); + const code = await codePromise; + expect(code).toBe("123456"); + }); + + it("should return 204 when no pattern matches", async () => { + const response = await fetch( + `http://localhost:${TEST_PORT}/capture-2fa`, + { + method: "POST", + body: "This message has no code in it", + }, + ); + + expect(response.status).toBe(204); + }); + + it("should be case insensitive", async () => { + const codePromise = await2FACode("generic-6digit", 5000); + + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "Your CODE: 111222", + }); + + const code = await codePromise; + expect(code).toBe("111222"); + }); + }); + + describe("Timeout Behavior", () => { + it.skip("should timeout after specified duration", async () => { + const start = Date.now(); + + try { + await await2FACode("generic-6digit", 1000); + expect.unreachable("Should have timed out"); + } catch (error) { + const duration = Date.now() - start; + expect(duration).toBeGreaterThanOrEqual(1000); + expect(duration).toBeLessThan(1200); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Timeout"); + } + }); + + it("should use default timeout of 60 seconds", async () => { + const codePromise = await2FACode("generic-6digit"); + + // Immediately send the code to resolve + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 999888", + }); + + const code = await codePromise; + expect(code).toBe("999888"); + }); + }); + + describe("Multiple Waiters", () => { + it("should resolve all pending requests for the same provider", async () => { + const promise1 = await2FACode("generic-6digit", 5000); + const promise2 = await2FACode("generic-6digit", 5000); + const promise3 = await2FACode("generic-6digit", 5000); + + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 444555", + }); + + const [code1, code2, code3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + expect(code1).toBe("444555"); + expect(code2).toBe("444555"); + expect(code3).toBe("444555"); + }); + }); + + describe("Server Lifecycle", () => { + it("should reject pending requests when server stops", async () => { + const codePromise = await2FACode("generic-6digit", 10000); + + stop2FAServer(); + + try { + await codePromise; + expect.unreachable("Should have rejected"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("2FA server stopped"); + } + }); + + it("should return 404 for unknown routes", async () => { + const response = await fetch(`http://localhost:${TEST_PORT}/unknown`); + expect(response.status).toBe(404); + }); + + it("should return 405 for non-POST requests to /capture-2fa", async () => { + const response = await fetch( + `http://localhost:${TEST_PORT}/capture-2fa`, + { + method: "GET", + }, + ); + expect(response.status).toBe(405); + }); + + it("should return 400 for empty request body", async () => { + const response = await fetch( + `http://localhost:${TEST_PORT}/capture-2fa`, + { + method: "POST", + body: "", + }, + ); + expect(response.status).toBe(400); + }); + }); + + describe("Code Caching", () => { + it("should immediately resolve with cached code if received before await", async () => { + // Capture a code first + const captureResponse = await fetch( + `http://localhost:${TEST_PORT}/capture-2fa`, + { + method: "POST", + body: "code: 123456", + }, + ); + expect(captureResponse.status).toBe(200); + + // Now await it - should resolve immediately + const code = await await2FACode("generic-6digit", 5000); + expect(code).toBe("123456"); + }); + + it("should wait for new code if cached code is expired", async () => { + // Capture a code first + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 111111", + }); + + // Wait longer than the reverse timeout (default 10s) + await new Promise((resolve) => setTimeout(resolve, 15)); + + // Now await it with a very short reverse timeout (1ms) - should wait for new code + const codePromise = await2FACode("generic-6digit", 5000, 1); + + // Send a new code + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 222222", + }); + + const code = await codePromise; + expect(code).toBe("222222"); + }); + + it("should remove code from cache after using it", async () => { + // Capture a code + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 333333", + }); + + // First await should get the cached code + const code1 = await await2FACode("generic-6digit", 5000); + expect(code1).toBe("333333"); + + // Second await should wait for a new code (not get the cached one) + const codePromise = await2FACode("generic-6digit", 5000); + + // Send a new code + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 444444", + }); + + const code2 = await codePromise; + expect(code2).toBe("444444"); + }); + + it("should respect custom reverse timeout", async () => { + // Capture a code + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 555555", + }); + + // Wait a short time + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Await with a custom reverse timeout of 100ms - should still get cached code + const code = await await2FACode("generic-6digit", 5000, 100); + expect(code).toBe("555555"); + }); + + it("should handle different providers independently in cache", async () => { + // Capture codes for different providers + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "code: 666666", + }); + + await fetch(`http://localhost:${TEST_PORT}/capture-2fa`, { + method: "POST", + body: "Your Standard Life verification code is 777777", + }); + + // Await should get the correct cached code for each provider + const genericCode = await await2FACode("generic-6digit", 5000); + const standardLifeCode = await await2FACode("standard-life-uk", 5000); + + expect(genericCode).toBe("666666"); + expect(standardLifeCode).toBe("777777"); + }); + }); +}); diff --git a/src/2fa.ts b/src/2fa.ts new file mode 100644 index 0000000..c93f1f3 --- /dev/null +++ b/src/2fa.ts @@ -0,0 +1,203 @@ +import { createLogger } from "./logger.ts"; + +const logger = createLogger("2FA"); + +interface Pattern { + name: string; + regex: RegExp; +} + +interface PendingRequest { + resolve: (code: string) => void; + reject: (error: Error) => void; + timeoutId: Timer; +} + +interface CachedCode { + code: string; + timestamp: number; +} + +// Registry of 2FA patterns to match against +const patterns: Pattern[] = [ + { + name: "generic-6digit", + regex: /code[:\s]*([0-9]{6})/i, + }, + { + name: "standard-life-uk", + regex: /Your Standard Life verification code is ([0-9]{6})/, + }, +]; + +// Default timeout for reverse caching (10 seconds) +const DEFAULT_REVERSE_TIMEOUT_MS = 10000; + +// Store for pending 2FA code requests +const pendingRequests = new Map(); + +// Cache for recently captured 2FA codes +const cachedCodes = new Map(); + +let server: ReturnType | null = null; + +/** + * Handles incoming 2FA message capture requests + */ +async function handleCapture(req: Request): Promise { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const text = await req.text(); + + if (!text) { + return new Response("Empty request body", { status: 400 }); + } + + // Try to match against all patterns + for (const pattern of patterns) { + const match = pattern.regex.exec(text); + + if (match?.[1]) { + const code = match[1]; + logger.info({ provider: pattern.name }, "2FA code captured"); + + // Store code in cache with timestamp + cachedCodes.set(pattern.name, { + code, + timestamp: Date.now(), + }); + + // Resolve all pending requests for this provider + const pending = pendingRequests.get(pattern.name); + if (pending) { + for (const request of pending) { + clearTimeout(request.timeoutId); + request.resolve(code); + } + pendingRequests.delete(pattern.name); + } + + return new Response("OK", { status: 200 }); + } + } + + // No pattern matched + return new Response("No match", { status: 204 }); +} + +/** + * Starts the 2FA capture HTTP server + */ +export function start2FAServer(port: number): void { + if (server) { + logger.warn("2FA server already running"); + return; + } + + server = Bun.serve({ + port, + fetch: async (req) => { + const url = new URL(req.url); + + if (url.pathname === "/capture-2fa") { + return handleCapture(req); + } + + return new Response("Not found", { status: 404 }); + }, + }); + + logger.info({ port }, "2FA server started"); +} + +/** + * Stops the 2FA capture HTTP server + */ +export function stop2FAServer(): void { + if (!server) { + return; + } + + server.stop(); + server = null; + + // Reject all pending requests + for (const [_provider, requests] of pendingRequests) { + for (const request of requests) { + clearTimeout(request.timeoutId); + request.reject(new Error("2FA server stopped")); + } + } + pendingRequests.clear(); + + // Clear cached codes + cachedCodes.clear(); + + logger.info("2FA server stopped"); +} + +/** + * Waits for a 2FA code for the specified provider + * @param provider The name of the provider (must match a pattern name) + * @param timeoutMs Timeout in milliseconds (default: 60000) + * @param reverseTimeoutMs Maximum age of cached code to use in milliseconds (default: 10000) + * @returns Promise that resolves with the 2FA code or rejects on timeout + */ +export function await2FACode( + provider: string, + timeoutMs = 60000, + reverseTimeoutMs = DEFAULT_REVERSE_TIMEOUT_MS, +): Promise { + // Check if we have a recently cached code + const cached = cachedCodes.get(provider); + if (cached) { + const age = Date.now() - cached.timestamp; + if (age <= reverseTimeoutMs) { + logger.debug( + { provider, age }, + "Using cached 2FA code from before await", + ); + // Remove from cache and return immediately + cachedCodes.delete(provider); + return Promise.resolve(cached.code); + } + // Code is too old, remove it + logger.debug({ provider, age }, "Cached 2FA code expired"); + cachedCodes.delete(provider); + } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + // Remove this request from pending + const pending = pendingRequests.get(provider); + if (pending) { + const index = pending.findIndex((r) => r.timeoutId === timeoutId); + if (index !== -1) { + pending.splice(index, 1); + } + if (pending.length === 0) { + pendingRequests.delete(provider); + } + } + + reject(new Error(`Timeout waiting for 2FA code from ${provider}`)); + }, timeoutMs); + + const request: PendingRequest = { + resolve, + reject, + timeoutId, + }; + + const existing = pendingRequests.get(provider); + if (existing) { + existing.push(request); + } else { + pendingRequests.set(provider, [request]); + } + + logger.debug({ provider, timeout: timeoutMs }, "Waiting for 2FA code"); + }); +} diff --git a/src/browser.ts b/src/browser.ts index d0bd2ca..2c6ace9 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,4 +1,4 @@ -import puppeteer from "puppeteer-core"; +import puppeteer from "puppeteer"; import config from "./config.ts"; export const isBrowserAvailable = async () => { @@ -14,7 +14,16 @@ export const isBrowserAvailable = async () => { export const getBrowser = async () => { const endpoint = config.browser?.endpoint; + const isProduction = Bun.env.NODE_ENV === "production"; + // In non-production environments without an endpoint, launch a headful browser + if (!isProduction && !endpoint) { + return puppeteer.launch({ + headless: false, + }); + } + + // In production or when an endpoint is configured, connect to the endpoint if (!endpoint) { throw new Error("Browser endpoint is not configured"); } diff --git a/src/config.ts b/src/config.ts index 8415927..bc1ba03 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,13 @@ const accountConfig = z.discriminatedUnion("type", [ .string() .min(1, "UK Student Loan secret answer is required"), }), + z.object({ + ...commonFields, + type: z.literal("standard_life_pension"), + username: z.string().min(1, "Standard Life username is required"), + password: z.string().min(1, "Standard Life password is required"), + policyNumber: z.string().min(1, "Standard Life policy number is required"), + }), ]); export type Account = z.infer; @@ -44,6 +51,11 @@ const schemaConfig = z.object({ endpoint: z.string(), }) .optional(), + server: z + .object({ + port: z.number().int().positive().default(4030), + }) + .default({ port: 4030 }), accounts: accountConfig .array() .min(1, "At least one account must be configured"), diff --git a/src/connectors/index.ts b/src/connectors/index.ts index 92fe2c6..f83b2ec 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -1,4 +1,5 @@ import type config from "../config.ts"; +import { standardLifePensionConnector } from "./standardLifePension.ts"; import { getTrading212Balance } from "./trading212.ts"; import { getUkStudentLoanBalance } from "./ukStudentLoan.ts"; @@ -39,6 +40,7 @@ const connectors: { ); }, }, + standard_life_pension: standardLifePensionConnector, }; type AccountTransaction = { @@ -59,6 +61,14 @@ type AccountResult = canRetry: boolean; }; +interface Connector { + friendlyName: string; + + getBalance: ( + account: (typeof config.accounts)[number], + ) => Promise; +} + export { connectors }; -export type { AccountResult, AccountTransaction }; +export type { AccountResult, AccountTransaction, Connector }; diff --git a/src/connectors/standardLifePension.ts b/src/connectors/standardLifePension.ts new file mode 100644 index 0000000..abcf0e7 --- /dev/null +++ b/src/connectors/standardLifePension.ts @@ -0,0 +1,97 @@ +import { await2FACode } from "../2fa.ts"; +import { getBrowser } from "../browser.ts"; +import type config from "../config.ts"; +import type { AccountResult, Connector } from "./index.ts"; + +type AccountType = (typeof config.accounts)[number]; + +const STANDARD_LIFE_PENSION_AUTH_URL = + "https://online.standardlife.com/secure/customer-authentication-client/customer/login"; +const STANDARD_LIFE_DASHBOARD_URL = + "https://platform.secure.standardlife.co.uk/secure/customer-platform/dashboard"; +const STANDARD_LIFE_PENSION_POLICY_URL = + "https://platform.secure.standardlife.co.uk/secure/customer-platform/pension/details?policy="; + +const parseBalanceString = (input: string): number => { + const m = input.match(/£\s*([\d,]+(?:\.\d+)?)/); + if (!m) return 0; + return parseFloat(m[1]?.replace(/,/g, "") || "0"); +}; + +class StandardLifePensionConnector implements Connector { + friendlyName = "Standard Life Pension"; + + async getBalance(account: AccountType): Promise { + if (account.type !== "standard_life_pension") { + throw new Error( + "Invalid account type for Standard Life Pension connector", + ); + } + + // get a browser instance + const browser = await getBrowser(); + + // sign in + const page = await browser.newPage(); + await page.goto(STANDARD_LIFE_PENSION_AUTH_URL); + await page.type("#userid", account.username); + await page.type("#password", account.password); + await page.click("#submit"); + + // check if we need to input a 2FA code + await page.waitForNetworkIdle(); + const pageUrl = page.url(); + + if (pageUrl !== STANDARD_LIFE_DASHBOARD_URL) { + // wait for 2FA code + const twoFactorCode = await await2FACode( + "standard-life-uk", + 15000, + 10000, + ).catch(() => null); + + if (twoFactorCode) { + await page.type("#OTPcode", twoFactorCode); + await page.click("#trustDevice"); + await page.click("#verifyCode"); + + // wait for navigation to dashboard + await page.waitForNetworkIdle(); + } else { + await page.close(); + + return { + error: "2FA code required but not received in time", + canRetry: true, + }; + } + } + + // go to policy page + await page.goto( + `${STANDARD_LIFE_PENSION_POLICY_URL}${account.policyNumber}`, + ); + + // get balance from class + const balanceText = await page.waitForSelector(".we_hud-plan-value-amount"); + const value = await balanceText?.evaluate((el) => el.textContent); + + if (!value) { + await page.close(); + return { + error: "Could not find balance element on page", + canRetry: true, + }; + } + + const balance = parseBalanceString(value); + + await page.close(); + + return { + balance, + }; + } +} + +export const standardLifePensionConnector = new StandardLifePensionConnector(); diff --git a/src/connectors/trading212.test.ts b/src/connectors/trading212.test.ts index 42933bb..fc86ee6 100644 --- a/src/connectors/trading212.test.ts +++ b/src/connectors/trading212.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { getTrading212Balance } from "./trading212.ts"; describe("Trading 212", () => { it("should return an error if API key is invalid", async () => { diff --git a/src/index.ts b/src/index.ts index 77f054c..f7626fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import cron, { type ScheduledTask } from "node-cron"; +import { start2FAServer, stop2FAServer } from "./2fa.ts"; import config from "./config.ts"; import logger, { createLogger } from "./logger.ts"; import { runSyncJob } from "./runtime.ts"; @@ -18,6 +19,9 @@ if (!budgetExists) { logger.info(`Using YNAB budget ID: ${config.ynab.budgetId}`); +// start 2FA server +start2FAServer(config.server.port); + // schedule jobs for each account const jobs: Map = new Map(); @@ -42,7 +46,9 @@ for (const account of config.accounts) { `Scheduled job successfully`, ); - await task.execute(); + if (Bun.env.NODE_ENV !== "production") { + await task.execute(); + } } // schedule summary job @@ -66,6 +72,8 @@ await summaryJob.execute(); const shutdown = () => { logger.info("Shutting down..."); + stop2FAServer(); + for (const [name, job] of jobs) { logger.info(`Stopping job for account "${name}"`); job.stop(); diff --git a/src/logger.ts b/src/logger.ts index 33c55c6..c00ccb6 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,11 +1,17 @@ import pino from "pino"; +const getLogLevel = () => { + if (Bun.env.NODE_ENV === "test") return "silent"; + if (Bun.env.NODE_ENV === "production") return "info"; + return "debug"; +}; + const createLogger = (name?: string) => pino({ name, - level: Bun.env.NODE_ENV === "production" ? "info" : "debug", + level: getLogLevel(), transport: - Bun.env.NODE_ENV === "production" + Bun.env.NODE_ENV === "production" || Bun.env.NODE_ENV === "test" ? undefined : { target: "pino-pretty",