├── .nvmrc ├── .env-example ├── .eslintignore ├── .npmrc ├── src ├── vite-env.d.ts ├── assets │ └── style │ │ └── theme.scss ├── shared │ ├── types.ts │ ├── hooks │ │ ├── useAlarm.ts │ │ └── useStorage.tsx │ ├── hoc │ │ ├── withSuspense.tsx │ │ └── withErrorBoundary.tsx │ ├── storages │ │ ├── transactionStorage.ts │ │ ├── debugStorage.ts │ │ ├── progressStorage.ts │ │ ├── appStorage.ts │ │ └── base.ts │ └── api │ │ ├── monarchApi.ts │ │ ├── matchUtil.ts │ │ └── amazonApi.ts ├── environment.d.ts ├── pages │ ├── popup │ │ ├── index.html │ │ ├── index.css │ │ ├── index.tsx │ │ ├── components │ │ │ ├── YearSelector.tsx │ │ │ ├── ConnectionInfo.tsx │ │ │ └── ProgressIndicator.tsx │ │ ├── Popup.tsx │ │ ├── ManualBackfill.tsx │ │ ├── Options.tsx │ │ └── Main.tsx │ └── background │ │ └── index.ts └── global.d.ts ├── .husky └── pre-commit ├── public ├── icon-128.png └── icon-34.png ├── postcss.config.js ├── utils ├── reload │ ├── constant.ts │ ├── utils.ts │ ├── injections │ │ ├── script.ts │ │ └── view.ts │ ├── interpreter │ │ ├── index.ts │ │ └── types.ts │ ├── rollup.config.mjs │ ├── initReloadClient.ts │ └── initReloadServer.ts ├── plugins │ ├── custom-dynamic-import.ts │ ├── watch-rebuild.ts │ ├── add-hmr.ts │ └── make-manifest.ts ├── manifest-parser │ └── index.ts └── log.ts ├── .prettierignore ├── .prettierrc ├── .gitignore ├── tailwind.config.js ├── .release-it.json ├── .github ├── dependabot.yml └── workflows │ └── build-zip.yml ├── .eslintrc ├── tsconfig.json ├── LICENSE ├── manifest.js ├── vite.config.ts ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.0 2 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=@testing-library/dom 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/assets/style/theme.scss: -------------------------------------------------------------------------------- 1 | .crx-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-peck/monarch-amazon-sync/HEAD/public/icon-128.png -------------------------------------------------------------------------------- /public/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-peck/monarch-amazon-sync/HEAD/public/icon-34.png -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export enum Action { 2 | DryRun = 'DRY_RUN', 3 | FullSync = 'FULL_SYNC', 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /utils/reload/constant.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_RELOAD_SOCKET_PORT = 8081; 2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .gitignore 4 | .github 5 | .eslintignore 6 | .husky 7 | .nvmrc 8 | .prettierignore 9 | LICENSE 10 | *.md 11 | pnpm-lock.yaml -------------------------------------------------------------------------------- /src/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | __DEV__: string; 5 | __FIREFOX__: string; 6 | } 7 | } 8 | } 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "bracketSameLine": true, 8 | "htmlWhitespaceSensitivity": "strict" 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /utils/reload/utils.ts: -------------------------------------------------------------------------------- 1 | import { clearTimeout } from 'timers'; 2 | 3 | export function debounce(callback: (...args: A) => void, delay: number) { 4 | let timer: NodeJS.Timeout; 5 | return function (...args: A) { 6 | clearTimeout(timer); 7 | timer = setTimeout(() => callback(...args), delay); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/hooks/useAlarm.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useAlarm = (alarmName: string) => { 4 | const [alarm, setAlarm] = useState(undefined); 5 | 6 | useEffect(() => { 7 | chrome.alarms.get(alarmName, setAlarm); 8 | }, [alarmName]); 9 | 10 | return alarm; 11 | }; 12 | -------------------------------------------------------------------------------- /utils/reload/injections/script.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from '../initReloadClient'; 2 | 3 | export default function addHmrIntoScript(watchPath: string) { 4 | const reload = () => { 5 | chrome.runtime.reload(); 6 | }; 7 | 8 | initReloadClient({ 9 | watchPath, 10 | onUpdate: reload, 11 | onForceReload: reload, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # build 8 | /dist 9 | chrome-monarch-amazon-sync.zip 10 | firefox-monarch-amazon-sync.zip 11 | 12 | # etc 13 | .DS_Store 14 | .env.local 15 | .idea 16 | 17 | # env 18 | .env 19 | 20 | # compiled 21 | utils/reload/*.js 22 | utils/reload/injections/*.js 23 | public/manifest.json 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: ['./src/**/*.{js,jsx,ts,tsx}', 'node_modules/flowbite-react/lib/esm/**/*.js'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require('flowbite/plugin')], 7 | variants: { 8 | extend: { 9 | opacity: ['disabled'], 10 | }, 11 | }, 12 | corePlugins: { 13 | preflight: true, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "after:bump": "pnpm run build-and-zip && pnpm run build-and-zip:firefox" 4 | }, 5 | "git": { 6 | "commitMessage": "Release v${version}" 7 | }, 8 | "github": { 9 | "release": true, 10 | "releaseName": "v${version}", 11 | "assets": ["chrome-monarch-amazon-sync.zip", "firefox-monarch-amazon-sync.zip"], 12 | "autoGenerate": true 13 | }, 14 | "npm": { 15 | "publish": false 16 | } 17 | } -------------------------------------------------------------------------------- /src/shared/hoc/withSuspense.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, ReactElement, Suspense } from 'react'; 2 | 3 | export default function withSuspense>( 4 | Component: ComponentType, 5 | SuspenseComponent: ReactElement, 6 | ) { 7 | return function WithSuspense(props: T) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | width: 320px; 7 | height: 400px; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 10 | 'Droid Sans', 'Helvetica Neue', sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | 14 | position: relative; 15 | } 16 | -------------------------------------------------------------------------------- /utils/reload/interpreter/index.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocketMessage, SerializedMessage } from './types'; 2 | 3 | export default class MessageInterpreter { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static send(message: WebSocketMessage): SerializedMessage { 8 | return JSON.stringify(message); 9 | } 10 | static receive(serializedMessage: SerializedMessage): WebSocketMessage { 11 | return JSON.parse(serializedMessage); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '@pages/popup/index.css'; 4 | import refreshOnUpdate from 'virtual:reload-on-update-in-view'; 5 | import Popup from './Popup'; 6 | 7 | refreshOnUpdate('pages/popup'); 8 | 9 | function init() { 10 | const appContainer = document.querySelector('#app-container'); 11 | if (!appContainer) { 12 | throw new Error('Can not find #app-container'); 13 | } 14 | const root = createRoot(appContainer); 15 | root.render(); 16 | } 17 | 18 | init(); 19 | -------------------------------------------------------------------------------- /utils/reload/interpreter/types.ts: -------------------------------------------------------------------------------- 1 | type UpdatePendingMessage = { 2 | type: 'wait_update'; 3 | path: string; 4 | }; 5 | type UpdateRequestMessage = { 6 | type: 'do_update'; 7 | }; 8 | type UpdateCompleteMessage = { type: 'done_update' }; 9 | type BuildCompletionMessage = { type: 'build_complete' }; 10 | type ForceReloadMessage = { type: 'force_reload' }; 11 | 12 | export type SerializedMessage = string; 13 | export type WebSocketMessage = 14 | | UpdateCompleteMessage 15 | | UpdateRequestMessage 16 | | UpdatePendingMessage 17 | | BuildCompletionMessage 18 | | ForceReloadMessage; 19 | -------------------------------------------------------------------------------- /utils/reload/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | 3 | const plugins = [typescript()]; 4 | 5 | export default [ 6 | { 7 | plugins, 8 | input: 'utils/reload/initReloadServer.ts', 9 | output: { 10 | file: 'utils/reload/initReloadServer.js', 11 | }, 12 | external: ['ws', 'chokidar', 'timers'], 13 | }, 14 | { 15 | plugins, 16 | input: 'utils/reload/injections/script.ts', 17 | output: { 18 | file: 'utils/reload/injections/script.js', 19 | }, 20 | }, 21 | { 22 | plugins, 23 | input: 'utils/reload/injections/view.ts', 24 | output: { 25 | file: 'utils/reload/injections/view.js', 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /utils/plugins/custom-dynamic-import.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | 3 | export default function customDynamicImport(): PluginOption { 4 | return { 5 | name: 'custom-dynamic-import', 6 | renderDynamicImport({ moduleId }) { 7 | if (!moduleId.includes('node_modules') && process.env.__FIREFOX__) { 8 | return { 9 | left: ` 10 | { 11 | const dynamicImport = (path) => import(path); 12 | dynamicImport(browser.runtime.getURL('./') + 13 | `, 14 | right: ".split('../').join(''))}", 15 | }; 16 | } 17 | return { 18 | left: 'import(', 19 | right: ')', 20 | }; 21 | }, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/storages/transactionStorage.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, StorageType } from '@src/shared/storages/base'; 2 | import { Order } from '../api/amazonApi'; 3 | import { MonarchTransaction } from '../api/monarchApi'; 4 | 5 | export enum TransactionStatus { 6 | Pending = 'pending', 7 | Success = 'success', 8 | Error = 'error', 9 | } 10 | 11 | type State = { 12 | result: TransactionStatus; 13 | orders: Order[]; 14 | transactions: MonarchTransaction[]; 15 | }; 16 | 17 | const transactionStorage = createStorage( 18 | 'transactions', 19 | { 20 | orders: [], 21 | transactions: [], 22 | result: TransactionStatus.Pending, 23 | }, 24 | { 25 | storageType: StorageType.Local, 26 | liveUpdate: true, 27 | }, 28 | ); 29 | 30 | export default transactionStorage; 31 | -------------------------------------------------------------------------------- /utils/reload/injections/view.ts: -------------------------------------------------------------------------------- 1 | import initReloadClient from '../initReloadClient'; 2 | 3 | export default function addHmrIntoView(watchPath: string) { 4 | let pendingReload = false; 5 | 6 | initReloadClient({ 7 | watchPath, 8 | onUpdate: () => { 9 | // disable reload when tab is hidden 10 | if (document.hidden) { 11 | pendingReload = true; 12 | return; 13 | } 14 | reload(); 15 | }, 16 | }); 17 | 18 | // reload 19 | function reload(): void { 20 | pendingReload = false; 21 | window.location.reload(); 22 | } 23 | 24 | // reload when tab is visible 25 | function reloadWhenTabIsVisible(): void { 26 | !document.hidden && pendingReload && reload(); 27 | } 28 | document.addEventListener('visibilitychange', reloadWhenTabIsVisible); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/build-zip.yml: -------------------------------------------------------------------------------- 1 | name: Build And Upload Extension Zip Via Artifact 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version-file: ".nvmrc" 20 | 21 | - uses: actions/cache@v3 22 | with: 23 | path: node_modules 24 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 25 | 26 | - uses: pnpm/action-setup@v2 27 | 28 | - run: pnpm install --frozen-lockfile 29 | 30 | - run: pnpm build 31 | 32 | - uses: actions/upload-artifact@v3 33 | with: 34 | path: dist/* 35 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virtual:reload-on-update-in-background-script' { 2 | export const reloadOnUpdate: (watchPath: string) => void; 3 | export default reloadOnUpdate; 4 | } 5 | 6 | declare module 'virtual:reload-on-update-in-view' { 7 | const refreshOnUpdate: (watchPath: string) => void; 8 | export default refreshOnUpdate; 9 | } 10 | 11 | declare module '*.svg' { 12 | import React = require('react'); 13 | export const ReactComponent: React.SFC>; 14 | const src: string; 15 | export default src; 16 | } 17 | 18 | declare module '*.jpg' { 19 | const content: string; 20 | export default content; 21 | } 22 | 23 | declare module '*.png' { 24 | const content: string; 25 | export default content; 26 | } 27 | 28 | declare module '*.json' { 29 | const content: string; 30 | export default content; 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/storages/debugStorage.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, StorageType } from '@src/shared/storages/base'; 2 | 3 | type State = { 4 | logs: string[]; 5 | }; 6 | 7 | const debugStorage = createStorage( 8 | 'debug', 9 | { 10 | logs: [], 11 | }, 12 | { 13 | storageType: StorageType.Local, 14 | liveUpdate: true, 15 | }, 16 | ); 17 | 18 | export async function debugLog(val: unknown) { 19 | let stringValue: string; 20 | if (typeof val === 'object') { 21 | stringValue = (val as Error).stack ?? JSON.stringify(val); 22 | } else if (typeof val === 'string') { 23 | stringValue = val; 24 | } else { 25 | stringValue = val?.toString() || ''; 26 | } 27 | await debugStorage.set(state => ({ 28 | logs: (state?.logs ?? []).concat([stringValue]), 29 | })); 30 | console.log(val); 31 | } 32 | 33 | export default debugStorage; 34 | -------------------------------------------------------------------------------- /utils/plugins/watch-rebuild.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import { WebSocket } from 'ws'; 3 | import MessageInterpreter from '../reload/interpreter'; 4 | import { LOCAL_RELOAD_SOCKET_URL } from '../reload/constant'; 5 | 6 | export default function watchRebuild(config: { afterWriteBundle: () => void }): PluginOption { 7 | const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 8 | return { 9 | name: 'watch-rebuild', 10 | writeBundle() { 11 | /** 12 | * When the build is complete, send a message to the reload server. 13 | * The reload server will send a message to the client to reload or refresh the extension. 14 | */ 15 | ws.send(MessageInterpreter.send({ type: 'build_complete' })); 16 | 17 | sendNextQueue(() => { 18 | config.afterWriteBundle(); 19 | }); 20 | }, 21 | }; 22 | } 23 | 24 | function sendNextQueue(callback: () => void) { 25 | setTimeout(() => { 26 | callback(); 27 | }, 0); 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:import/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "prettier" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": "latest", 22 | "sourceType": "module" 23 | }, 24 | "plugins": ["react", "@typescript-eslint", "react-hooks", "import", "jsx-a11y", "prettier"], 25 | "settings": { 26 | "react": { 27 | "version": "detect" 28 | } 29 | }, 30 | "rules": { 31 | "react/react-in-jsx-scope": "off", 32 | "import/no-unresolved": "off" 33 | }, 34 | "globals": { 35 | "chrome": "readonly" 36 | }, 37 | "ignorePatterns": ["watch.js", "dist/**"] 38 | } 39 | -------------------------------------------------------------------------------- /src/shared/hoc/withErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentType, ErrorInfo, ReactElement } from 'react'; 2 | 3 | class ErrorBoundary extends Component< 4 | { 5 | children: ReactElement; 6 | fallback: ReactElement; 7 | }, 8 | { 9 | hasError: boolean; 10 | } 11 | > { 12 | state = { hasError: false }; 13 | 14 | static getDerivedStateFromError() { 15 | return { hasError: true }; 16 | } 17 | 18 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 19 | console.error(error, errorInfo); 20 | } 21 | 22 | render() { 23 | if (this.state.hasError) { 24 | return this.props.fallback; 25 | } 26 | 27 | return this.props.children; 28 | } 29 | } 30 | 31 | export default function withErrorBoundary>( 32 | Component: ComponentType, 33 | ErrorComponent: ReactElement, 34 | ) { 35 | return function WithErrorBoundary(props: T) { 36 | return ( 37 | 38 | 39 | 40 | ); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/shared/storages/progressStorage.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, StorageType } from '@src/shared/storages/base'; 2 | 3 | export enum ProgressPhase { 4 | Idle = 'idle', 5 | AmazonPageScan = 'amazon_page_scan', 6 | AmazonOrderDownload = 'amazon_order_download', 7 | MonarchDownload = 'monarch_download', 8 | MonarchUpload = 'monarch_upload', 9 | Complete = 'complete', 10 | } 11 | 12 | export type ProgressState = { 13 | phase: ProgressPhase; 14 | total: number; 15 | complete: number; 16 | lastUpdated?: number; 17 | }; 18 | 19 | export async function updateProgress(phase: ProgressPhase, total: number, complete: number) { 20 | await progressStorage.set({ 21 | phase, 22 | total, 23 | complete, 24 | lastUpdated: Date.now(), 25 | }); 26 | } 27 | 28 | const progressStorage = createStorage( 29 | 'progress', 30 | { 31 | phase: ProgressPhase.Idle, 32 | total: 0, 33 | complete: 0, 34 | lastUpdated: 0, 35 | }, 36 | { 37 | storageType: StorageType.Local, 38 | liveUpdate: true, 39 | }, 40 | ); 41 | 42 | export default progressStorage; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": ".", 5 | "allowJs": false, 6 | "experimentalDecorators": true, 7 | "strict": true, 8 | "target": "esnext", 9 | "module": "esnext", 10 | "jsx": "react-jsx", 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "moduleResolution": "node", 15 | "types": ["vite/client", "node"], 16 | "noFallthroughCasesInSwitch": true, 17 | "allowSyntheticDefaultImports": true, 18 | "lib": ["dom", "dom.iterable", "esnext"], 19 | "forceConsistentCasingInFileNames": true, 20 | "typeRoots": ["./src/global.d.ts", "node_modules/@types"], 21 | "paths": { 22 | "@root/*": ["./*"], 23 | "@src/*": ["src/*"], 24 | "@assets/*": ["src/assets/*"], 25 | "@pages/*": ["src/pages/*"], 26 | "virtual:reload-on-update-in-background-script": ["./src/global.d.ts"], 27 | "virtual:reload-on-update-in-view": ["./src/global.d.ts"] 28 | } 29 | }, 30 | "include": ["src", "utils", "vite.config.ts", "node_modules/@types"] 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Seo Jong Hak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/pages/popup/components/YearSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Label, Select } from 'flowbite-react'; 2 | import { useMemo } from 'react'; 3 | 4 | type YearSelectorProps = { 5 | oldestYear: number | undefined; 6 | onSelect: (year: string) => void; 7 | }; 8 | 9 | export default function YearSelector({ oldestYear, onSelect }: YearSelectorProps) { 10 | const years = useMemo(() => { 11 | const currentYear = new Date().getFullYear(); 12 | if (oldestYear === undefined) return [`${currentYear}`]; 13 | 14 | const years: string[] = []; 15 | for (let i = currentYear; i >= oldestYear; i--) { 16 | years.push(`${i}`); 17 | } 18 | return years; 19 | }, [oldestYear]); 20 | 21 | return ( 22 | <> 23 |
24 |
26 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /utils/manifest-parser/index.ts: -------------------------------------------------------------------------------- 1 | type Manifest = chrome.runtime.ManifestV3; 2 | 3 | class ManifestParser { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static convertManifestToString(manifest: Manifest): string { 8 | if (process.env.__FIREFOX__) { 9 | manifest = this.convertToFirefoxCompatibleManifest(manifest); 10 | } 11 | return JSON.stringify(manifest, null, 2); 12 | } 13 | 14 | static convertToFirefoxCompatibleManifest(manifest: Manifest) { 15 | const manifestCopy = { 16 | ...manifest, 17 | } as { [key: string]: unknown }; 18 | 19 | manifestCopy.background = { 20 | scripts: [manifest.background?.service_worker], 21 | type: 'module', 22 | }; 23 | if (manifest.options_page) { 24 | manifestCopy.options_ui = { 25 | page: manifest.options_page, 26 | browser_style: false, 27 | }; 28 | delete manifestCopy.options_page; 29 | } 30 | 31 | manifestCopy.content_security_policy = { 32 | extension_pages: "script-src 'self'; object-src 'self'", 33 | }; 34 | return manifestCopy as Manifest; 35 | } 36 | } 37 | 38 | export default ManifestParser; 39 | -------------------------------------------------------------------------------- /manifest.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8')); 4 | 5 | /** 6 | * After changing, please reload the extension at `chrome://extensions` 7 | * @type {chrome.runtime.ManifestV3} 8 | */ 9 | const manifest = { 10 | manifest_version: 3, 11 | name: 'Monarch / Amazon Sync', 12 | version: packageJson.version, 13 | description: packageJson.description, 14 | permissions: ['storage', 'tabs', 'scripting', 'alarms', 'downloads'], 15 | host_permissions: [ 16 | 'https://amazon.com/*', 17 | 'https://www.amazon.com/*', 18 | 'https://app.monarchmoney.com/*', 19 | 'https://api.monarchmoney.com/*', 20 | ], 21 | background: { 22 | service_worker: 'src/pages/background/index.js', 23 | type: 'module', 24 | }, 25 | action: { 26 | default_popup: 'src/pages/popup/index.html', 27 | default_icon: 'icon-34.png', 28 | }, 29 | icons: { 30 | 128: 'icon-128.png', 31 | }, 32 | content_scripts: [], 33 | web_accessible_resources: [ 34 | { 35 | resources: ['assets/js/*.js', 'assets/css/*.css', 'icon-128.png', 'icon-34.png'], 36 | matches: ['*://*/*'], 37 | }, 38 | ], 39 | }; 40 | 41 | export default manifest; 42 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | type ColorType = 'success' | 'info' | 'error' | 'warning' | keyof typeof COLORS; 2 | type ValueOf = T[keyof T]; 3 | 4 | export default function colorLog(message: string, type: ColorType) { 5 | let color: ValueOf; 6 | 7 | switch (type) { 8 | case 'success': 9 | color = COLORS.FgGreen; 10 | break; 11 | case 'info': 12 | color = COLORS.FgBlue; 13 | break; 14 | case 'error': 15 | color = COLORS.FgRed; 16 | break; 17 | case 'warning': 18 | color = COLORS.FgYellow; 19 | break; 20 | default: 21 | color = COLORS[type]; 22 | break; 23 | } 24 | 25 | console.log(color, message); 26 | } 27 | 28 | const COLORS = { 29 | Reset: '\x1b[0m', 30 | Bright: '\x1b[1m', 31 | Dim: '\x1b[2m', 32 | Underscore: '\x1b[4m', 33 | Blink: '\x1b[5m', 34 | Reverse: '\x1b[7m', 35 | Hidden: '\x1b[8m', 36 | FgBlack: '\x1b[30m', 37 | FgRed: '\x1b[31m', 38 | FgGreen: '\x1b[32m', 39 | FgYellow: '\x1b[33m', 40 | FgBlue: '\x1b[34m', 41 | FgMagenta: '\x1b[35m', 42 | FgCyan: '\x1b[36m', 43 | FgWhite: '\x1b[37m', 44 | BgBlack: '\x1b[40m', 45 | BgRed: '\x1b[41m', 46 | BgGreen: '\x1b[42m', 47 | BgYellow: '\x1b[43m', 48 | BgBlue: '\x1b[44m', 49 | BgMagenta: '\x1b[45m', 50 | BgCyan: '\x1b[46m', 51 | BgWhite: '\x1b[47m', 52 | } as const; 53 | -------------------------------------------------------------------------------- /src/shared/hooks/useStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react'; 2 | import { BaseStorage } from '@src/shared/storages/base'; 3 | 4 | type WrappedPromise = ReturnType; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | const storageMap: Map, WrappedPromise> = new Map(); 7 | 8 | export default function useStorage< 9 | Storage extends BaseStorage, 10 | Data = Storage extends BaseStorage ? Data : unknown, 11 | >(storage: Storage) { 12 | const _data = useSyncExternalStore(storage.subscribe, storage.getSnapshot); 13 | 14 | if (!storageMap.has(storage)) { 15 | storageMap.set(storage, wrapPromise(storage.get())); 16 | } 17 | if (_data !== null) { 18 | storageMap.set(storage, { read: () => _data }); 19 | } 20 | 21 | return _data ?? (storageMap.get(storage)!.read() as Data); 22 | } 23 | 24 | function wrapPromise(promise: Promise) { 25 | let status = 'pending'; 26 | let result: R; 27 | const suspender = promise.then( 28 | r => { 29 | status = 'success'; 30 | result = r; 31 | }, 32 | e => { 33 | status = 'error'; 34 | result = e; 35 | }, 36 | ); 37 | 38 | return { 39 | read() { 40 | if (status === 'pending') { 41 | throw suspender; 42 | } else if (status === 'error') { 43 | throw result; 44 | } else if (status === 'success') { 45 | return result; 46 | } 47 | }, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /utils/plugins/add-hmr.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { readFileSync } from 'fs'; 3 | import type { PluginOption } from 'vite'; 4 | 5 | const isDev = process.env.__DEV__ === 'true'; 6 | 7 | const DUMMY_CODE = `export default function(){};`; 8 | 9 | function getInjectionCode(fileName: string): string { 10 | return readFileSync(path.resolve(__dirname, '..', 'reload', 'injections', fileName), { encoding: 'utf8' }); 11 | } 12 | 13 | type Config = { 14 | background?: boolean; 15 | view?: boolean; 16 | }; 17 | 18 | export default function addHmr(config?: Config): PluginOption { 19 | const { background = false, view = true } = config || {}; 20 | const idInBackgroundScript = 'virtual:reload-on-update-in-background-script'; 21 | const idInView = 'virtual:reload-on-update-in-view'; 22 | 23 | const scriptHmrCode = isDev ? getInjectionCode('script.js') : DUMMY_CODE; 24 | const viewHmrCode = isDev ? getInjectionCode('view.js') : DUMMY_CODE; 25 | 26 | return { 27 | name: 'add-hmr', 28 | resolveId(id) { 29 | if (id === idInBackgroundScript || id === idInView) { 30 | return getResolvedId(id); 31 | } 32 | }, 33 | load(id) { 34 | if (id === getResolvedId(idInBackgroundScript)) { 35 | return background ? scriptHmrCode : DUMMY_CODE; 36 | } 37 | 38 | if (id === getResolvedId(idInView)) { 39 | return view ? viewHmrCode : DUMMY_CODE; 40 | } 41 | }, 42 | }; 43 | } 44 | 45 | function getResolvedId(id: string) { 46 | return '\0' + id; 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/popup/components/ConnectionInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from 'flowbite-react'; 2 | import { FaTimesCircle } from 'react-icons/fa'; 3 | import { RiCheckboxCircleFill } from 'react-icons/ri'; 4 | 5 | export enum ConnectionStatus { 6 | Loading, 7 | Success, 8 | Error, 9 | } 10 | 11 | export type ConnectionInfoProps = { 12 | status: ConnectionStatus; 13 | name: string; 14 | message?: string | undefined; 15 | lastUpdated: number; 16 | }; 17 | 18 | function ConnectionInfo({ status, name, message, lastUpdated }: ConnectionInfoProps) { 19 | const lastUpdatedDate = new Date(lastUpdated); 20 | return ( 21 | <> 22 |
23 | {status === ConnectionStatus.Loading ? ( 24 | 25 | ) : status === ConnectionStatus.Success ? ( 26 | 27 | ) : ( 28 | 29 | )} 30 | {name} 31 |
32 | 33 | {status === ConnectionStatus.Loading 34 | ? 'Loading...' 35 | : status === ConnectionStatus.Success 36 | ? `Last updated: ${lastUpdatedDate.toLocaleString()}` 37 | : 'Not connected'} 38 | 39 | {message && {message}} 40 | 41 | ); 42 | } 43 | 44 | export default ConnectionInfo; 45 | -------------------------------------------------------------------------------- /utils/reload/initReloadClient.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_RELOAD_SOCKET_URL } from './constant'; 2 | import MessageInterpreter from './interpreter'; 3 | 4 | let needToUpdate = false; 5 | 6 | export default function initReloadClient({ 7 | watchPath, 8 | onUpdate, 9 | onForceReload, 10 | }: { 11 | watchPath: string; 12 | onUpdate: () => void; 13 | onForceReload?: () => void; 14 | }): WebSocket { 15 | const socket = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 16 | 17 | function sendUpdateCompleteMessage() { 18 | socket.send(MessageInterpreter.send({ type: 'done_update' })); 19 | } 20 | 21 | socket.addEventListener('message', event => { 22 | const message = MessageInterpreter.receive(String(event.data)); 23 | 24 | switch (message.type) { 25 | case 'do_update': { 26 | if (needToUpdate) { 27 | sendUpdateCompleteMessage(); 28 | needToUpdate = false; 29 | onUpdate(); 30 | } 31 | return; 32 | } 33 | case 'wait_update': { 34 | if (!needToUpdate) { 35 | needToUpdate = message.path.includes(watchPath); 36 | } 37 | return; 38 | } 39 | case 'force_reload': { 40 | onForceReload?.(); 41 | return; 42 | } 43 | } 44 | }); 45 | 46 | socket.onclose = () => { 47 | console.log( 48 | `Reload server disconnected.\nPlease check if the WebSocket server is running properly on ${LOCAL_RELOAD_SOCKET_URL}. This feature detects changes in the code and helps the browser to reload the extension or refresh the current tab.`, 49 | ); 50 | setTimeout(() => { 51 | initReloadClient({ watchPath, onUpdate }); 52 | }, 1000); 53 | }; 54 | 55 | return socket; 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import useStorage from '@root/src/shared/hooks/useStorage'; 2 | import Options from './Options'; 3 | import Main from './Main'; 4 | import ManualBackfill from './ManualBackfill'; 5 | import { Navbar } from 'flowbite-react'; 6 | import appStorage, { Page } from '@root/src/shared/storages/appStorage'; 7 | 8 | const Popup = () => { 9 | const storage = useStorage(appStorage); 10 | 11 | let page; 12 | if (storage.page === Page.Options) { 13 | page = ; 14 | } else if (storage.page === Page.ManualBackfill) { 15 | page = ; 16 | } else { 17 | page =
; 18 | } 19 | 20 | return ( 21 |
22 | 23 | 24 | logo 25 | 26 | Monarch / Amazon Sync 27 | 28 | 29 | 30 | 31 | { 34 | appStorage.patch({ page: Page.Default }); 35 | }}> 36 | Home 37 | 38 | { 41 | appStorage.patch({ page: Page.Options }); 42 | }}> 43 | Options 44 | 45 | { 48 | appStorage.patch({ page: Page.ManualBackfill }); 49 | }}> 50 | Manual backfill 51 | 52 | 53 | 54 | {page} 55 |
56 | ); 57 | }; 58 | 59 | export default Popup; 60 | -------------------------------------------------------------------------------- /utils/plugins/make-manifest.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import colorLog from '../log'; 4 | import ManifestParser from '../manifest-parser'; 5 | import type { PluginOption } from 'vite'; 6 | import url from 'url'; 7 | import * as process from 'process'; 8 | 9 | const { resolve } = path; 10 | 11 | const rootDir = resolve(__dirname, '..', '..'); 12 | const distDir = resolve(rootDir, 'dist'); 13 | const manifestFile = resolve(rootDir, 'manifest.js'); 14 | 15 | const getManifestWithCacheBurst = (): Promise<{ default: chrome.runtime.ManifestV3 }> => { 16 | const withCacheBurst = (path: string) => `${path}?${Date.now().toString()}`; 17 | /** 18 | * In Windows, import() doesn't work without file:// protocol. 19 | * So, we need to convert path to file:// protocol. (url.pathToFileURL) 20 | */ 21 | if (process.platform === 'win32') { 22 | return import(withCacheBurst(url.pathToFileURL(manifestFile).href)); 23 | } 24 | return import(withCacheBurst(manifestFile)); 25 | }; 26 | 27 | export default function makeManifest(config?: { getCacheInvalidationKey?: () => string }): PluginOption { 28 | function makeManifest(manifest: chrome.runtime.ManifestV3, to: string, cacheKey?: string) { 29 | if (!fs.existsSync(to)) { 30 | fs.mkdirSync(to); 31 | } 32 | const manifestPath = resolve(to, 'manifest.json'); 33 | if (cacheKey) { 34 | // Naming change for cache invalidation 35 | manifest.content_scripts?.forEach(script => { 36 | script.css &&= script.css.map(css => css.replace('', cacheKey)); 37 | }); 38 | } 39 | 40 | fs.writeFileSync(manifestPath, ManifestParser.convertManifestToString(manifest)); 41 | 42 | colorLog(`Manifest file copy complete: ${manifestPath}`, 'success'); 43 | } 44 | 45 | return { 46 | name: 'make-manifest', 47 | buildStart() { 48 | this.addWatchFile(manifestFile); 49 | }, 50 | async writeBundle() { 51 | const invalidationKey = config?.getCacheInvalidationKey?.(); 52 | const manifest = await getManifestWithCacheBurst(); 53 | makeManifest(manifest.default, distDir, invalidationKey); 54 | }, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /utils/reload/initReloadServer.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket, WebSocketServer } from 'ws'; 2 | import chokidar from 'chokidar'; 3 | import { LOCAL_RELOAD_SOCKET_PORT, LOCAL_RELOAD_SOCKET_URL } from './constant'; 4 | import MessageInterpreter from './interpreter'; 5 | import { debounce } from './utils'; 6 | 7 | const clientsThatNeedToUpdate: Set = new Set(); 8 | let needToForceReload = false; 9 | 10 | function initReloadServer() { 11 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT }); 12 | 13 | wss.on('listening', () => console.log(`[HRS] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`)); 14 | 15 | wss.on('connection', ws => { 16 | clientsThatNeedToUpdate.add(ws); 17 | 18 | ws.addEventListener('close', () => clientsThatNeedToUpdate.delete(ws)); 19 | ws.addEventListener('message', event => { 20 | if (typeof event.data !== 'string') return; 21 | 22 | const message = MessageInterpreter.receive(event.data); 23 | 24 | if (message.type === 'done_update') { 25 | ws.close(); 26 | } 27 | if (message.type === 'build_complete') { 28 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => ws.send(MessageInterpreter.send({ type: 'do_update' }))); 29 | if (needToForceReload) { 30 | needToForceReload = false; 31 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 32 | ws.send(MessageInterpreter.send({ type: 'force_reload' })), 33 | ); 34 | } 35 | } 36 | }); 37 | }); 38 | } 39 | 40 | /** CHECK:: src file was updated **/ 41 | const debounceSrc = debounce(function (path: string) { 42 | // Normalize path on Windows 43 | const pathConverted = path.replace(/\\/g, '/'); 44 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 45 | ws.send(MessageInterpreter.send({ type: 'wait_update', path: pathConverted })), 46 | ); 47 | }, 100); 48 | chokidar.watch('src', { ignorePermissionErrors: true }).on('all', (_, path) => debounceSrc(path)); 49 | 50 | /** CHECK:: manifest.js was updated **/ 51 | chokidar.watch('manifest.js', { ignorePermissionErrors: true }).on('all', () => { 52 | needToForceReload = true; 53 | }); 54 | 55 | initReloadServer(); 56 | -------------------------------------------------------------------------------- /src/pages/popup/ManualBackfill.tsx: -------------------------------------------------------------------------------- 1 | import useStorage from '@root/src/shared/hooks/useStorage'; 2 | import appStorage, { AuthStatus, Page } from '@root/src/shared/storages/appStorage'; 3 | import progressStorage, { ProgressPhase } from '@root/src/shared/storages/progressStorage'; 4 | import { Button, ToggleSwitch } from 'flowbite-react'; 5 | import { useCallback, useMemo, useState } from 'react'; 6 | import YearSelector from './components/YearSelector'; 7 | import { Action } from '@root/src/shared/types'; 8 | 9 | export function ManualBackfill() { 10 | const appData = useStorage(appStorage); 11 | const progress = useStorage(progressStorage); 12 | 13 | const [year, setYear] = useState(undefined); 14 | const [dryRun, setDryRun] = useState(false); 15 | 16 | const actionOngoing = useMemo( 17 | () => progress.phase !== ProgressPhase.Complete && progress.phase !== ProgressPhase.Idle, 18 | [progress], 19 | ); 20 | const ready = 21 | appData.amazonStatus === AuthStatus.Success && appData.monarchStatus === AuthStatus.Success && !actionOngoing; 22 | 23 | const runBackfill = useCallback(async () => { 24 | if (!ready) return; 25 | 26 | await appStorage.patch({ page: Page.Default }); 27 | await chrome.runtime.sendMessage({ action: dryRun ? Action.DryRun : Action.FullSync, payload: { year: year } }); 28 | }, [ready, dryRun, year]); 29 | 30 | return ( 31 |
32 |
33 | setYear(year)} /> 34 | 35 |
36 | { 40 | setDryRun(value); 41 | }} 42 | /> 43 | 44 | If you want to see what transactions would be synced without actually syncing them, you can turn on dry run. 45 | 46 |
47 |
48 | 49 | 52 |
53 | ); 54 | } 55 | 56 | export default ManualBackfill; 57 | -------------------------------------------------------------------------------- /src/shared/api/monarchApi.ts: -------------------------------------------------------------------------------- 1 | export type MonarchTransaction = { 2 | id: string; 3 | amount: number; 4 | date: string; 5 | notes: string; 6 | }; 7 | 8 | export async function updateMonarchTransaction(authKey: string, id: string, note: string) { 9 | const body = { 10 | operationName: 'Web_TransactionDrawerUpdateTransaction', 11 | variables: { 12 | input: { 13 | id: id, 14 | notes: note, 15 | }, 16 | }, 17 | query: ` 18 | mutation Web_TransactionDrawerUpdateTransaction($input: UpdateTransactionMutationInput!) { 19 | updateTransaction(input: $input) { 20 | transaction { 21 | id 22 | amount 23 | pending 24 | date 25 | } 26 | errors { 27 | fieldErrors { 28 | field 29 | messages 30 | } 31 | message 32 | code 33 | } 34 | } 35 | } 36 | `, 37 | }; 38 | 39 | await graphQLRequest(authKey, body); 40 | } 41 | 42 | export async function getTransactions( 43 | authKey: string, 44 | merchant: string, 45 | startDate?: Date, 46 | endDate?: Date, 47 | ): Promise { 48 | const body = { 49 | operationName: 'Web_GetTransactionsList', 50 | variables: { 51 | orderBy: 'date', 52 | limit: 1000, 53 | filters: { 54 | search: merchant, 55 | categories: [], 56 | accounts: [], 57 | startDate: startDate?.toISOString().split('T')[0] ?? undefined, 58 | endDate: endDate?.toISOString().split('T')[0] ?? undefined, 59 | tags: [], 60 | }, 61 | }, 62 | query: ` 63 | query Web_GetTransactionsList($offset: Int, $limit: Int, $filters: TransactionFilterInput, $orderBy: TransactionOrdering) { 64 | allTransactions(filters: $filters) { 65 | totalCount 66 | results(offset: $offset, limit: $limit, orderBy: $orderBy) { 67 | id 68 | amount 69 | pending 70 | date 71 | notes 72 | } 73 | } 74 | } 75 | `, 76 | }; 77 | 78 | const result = await graphQLRequest(authKey, body); 79 | return result.data.allTransactions.results; 80 | } 81 | 82 | async function graphQLRequest(authKey: string, body: unknown) { 83 | const result = await fetch('https://api.monarchmoney.com/graphql', { 84 | headers: { 85 | authorization: 'Token ' + authKey, 86 | accept: '*/*', 87 | 'accept-language': 'en-US,en;q=0.9', 88 | 'content-type': 'application/json', 89 | }, 90 | body: JSON.stringify(body), 91 | method: 'POST', 92 | }); 93 | return await result.json(); 94 | } 95 | -------------------------------------------------------------------------------- /src/shared/storages/appStorage.ts: -------------------------------------------------------------------------------- 1 | import { StorageType, createStorage } from '@src/shared/storages/base'; 2 | 3 | export enum Page { 4 | Default = 'default', 5 | Options = 'options', 6 | ManualBackfill = 'manualBackfill', 7 | } 8 | 9 | export enum AuthStatus { 10 | Pending = 'pending', 11 | NotLoggedIn = 'notLoggedIn', 12 | Success = 'success', 13 | Failure = 'failure', 14 | } 15 | 16 | export enum FailureReason { 17 | Unknown = 'unknown', 18 | NoAmazonOrders = 'noAmazonOrders', 19 | NoAmazonAuth = 'noAmazonAuth', 20 | AmazonError = 'amazonError', 21 | NoMonarchAuth = 'noMonarchAuth', 22 | MonarchError = 'monarchError', 23 | NoMonarchTransactions = 'noMonarchTransactions', 24 | } 25 | 26 | export const mapFailureReasonToMessage = (reason: FailureReason | undefined): string => { 27 | switch (reason) { 28 | case FailureReason.NoAmazonOrders: 29 | return 'No Amazon orders found'; 30 | case FailureReason.NoAmazonAuth: 31 | return 'Amazon authorization failed'; 32 | case FailureReason.AmazonError: 33 | return 'An error occurred while fetching Amazon orders'; 34 | case FailureReason.NoMonarchAuth: 35 | return 'Monarch authorization failed'; 36 | case FailureReason.MonarchError: 37 | return 'An error occurred while fetching Monarch transactions'; 38 | case FailureReason.NoMonarchTransactions: 39 | return 'No Monarch transactions found'; 40 | default: 41 | return 'Unknown'; 42 | } 43 | }; 44 | 45 | export type LastSync = { 46 | time: number; 47 | success: boolean; 48 | amazonOrders: number; 49 | monarchTransactions: number; 50 | transactionsUpdated: number; 51 | failureReason?: FailureReason | undefined; 52 | dryRun?: boolean; 53 | }; 54 | 55 | type Options = { 56 | overrideTransactions: boolean; 57 | amazonMerchant: string; 58 | syncEnabled: boolean; 59 | }; 60 | 61 | type State = { 62 | page: Page; 63 | oldestAmazonYear: number | undefined; 64 | amazonStatus: AuthStatus; 65 | lastAmazonAuth: number; 66 | monarchKey?: string; 67 | monarchStatus: AuthStatus; 68 | lastMonarchAuth: number; 69 | lastSync: LastSync | undefined; 70 | options: Options; 71 | }; 72 | 73 | const appStorage = createStorage( 74 | 'page', 75 | { 76 | page: Page.Default, 77 | oldestAmazonYear: undefined, 78 | amazonStatus: AuthStatus.NotLoggedIn, 79 | lastAmazonAuth: 0, 80 | monarchKey: undefined, 81 | monarchStatus: AuthStatus.NotLoggedIn, 82 | lastMonarchAuth: 0, 83 | lastSync: undefined, 84 | options: { 85 | overrideTransactions: false, 86 | amazonMerchant: 'Amazon', 87 | syncEnabled: false, 88 | }, 89 | }, 90 | { 91 | storageType: StorageType.Local, 92 | liveUpdate: true, 93 | }, 94 | ); 95 | 96 | export default appStorage; 97 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import path, { resolve } from 'path'; 5 | import makeManifest from './utils/plugins/make-manifest'; 6 | import customDynamicImport from './utils/plugins/custom-dynamic-import'; 7 | import addHmr from './utils/plugins/add-hmr'; 8 | import watchRebuild from './utils/plugins/watch-rebuild'; 9 | 10 | const rootDir = resolve(__dirname); 11 | const srcDir = resolve(rootDir, 'src'); 12 | const pagesDir = resolve(srcDir, 'pages'); 13 | const assetsDir = resolve(srcDir, 'assets'); 14 | const outDir = resolve(rootDir, 'dist'); 15 | const publicDir = resolve(rootDir, 'public'); 16 | 17 | const isDev = process.env.__DEV__ === 'true'; 18 | const isProduction = !isDev; 19 | 20 | // ENABLE HMR IN BACKGROUND SCRIPT 21 | const enableHmrInBackgroundScript = true; 22 | const cacheInvalidationKeyRef = { current: generateKey() }; 23 | 24 | export default defineConfig({ 25 | resolve: { 26 | alias: { 27 | '@root': rootDir, 28 | '@src': srcDir, 29 | '@assets': assetsDir, 30 | '@pages': pagesDir, 31 | }, 32 | }, 33 | plugins: [ 34 | makeManifest({ 35 | getCacheInvalidationKey, 36 | }), 37 | react(), 38 | customDynamicImport(), 39 | addHmr({ background: enableHmrInBackgroundScript, view: true }), 40 | isDev && watchRebuild({ afterWriteBundle: regenerateCacheInvalidationKey }), 41 | ], 42 | publicDir, 43 | build: { 44 | outDir, 45 | /** Can slow down build speed. */ 46 | // sourcemap: isDev, 47 | minify: isProduction, 48 | modulePreload: false, 49 | reportCompressedSize: isProduction, 50 | emptyOutDir: !isDev, 51 | rollupOptions: { 52 | input: { 53 | background: resolve(pagesDir, 'background', 'index.ts'), 54 | popup: resolve(pagesDir, 'popup', 'index.html'), 55 | }, 56 | output: { 57 | entryFileNames: 'src/pages/[name]/index.js', 58 | chunkFileNames: isDev ? 'assets/js/[name].js' : 'assets/js/[name].[hash].js', 59 | assetFileNames: assetInfo => { 60 | const name = assetInfo.name ? path.parse(assetInfo.name).name : 'unknown'; 61 | return `assets/[ext]/${name}.chunk.[ext]`; 62 | }, 63 | }, 64 | }, 65 | }, 66 | test: { 67 | globals: true, 68 | environment: 'jsdom', 69 | include: ['**/*.test.ts', '**/*.test.tsx'], 70 | setupFiles: './test-utils/vitest.setup.js', 71 | }, 72 | }); 73 | 74 | function getCacheInvalidationKey() { 75 | return cacheInvalidationKeyRef.current; 76 | } 77 | function regenerateCacheInvalidationKey() { 78 | cacheInvalidationKeyRef.current = generateKey(); 79 | return cacheInvalidationKeyRef; 80 | } 81 | 82 | function generateKey(): string { 83 | return `${Date.now().toFixed()}`; 84 | } 85 | -------------------------------------------------------------------------------- /src/shared/api/matchUtil.ts: -------------------------------------------------------------------------------- 1 | import { Item, Order, OrderTransaction } from './amazonApi'; 2 | import { MonarchTransaction } from './monarchApi'; 3 | 4 | export type MatchedTransaction = { 5 | monarch: MonarchTransaction; 6 | amazon: OrderTransaction; 7 | items: Item[]; 8 | }; 9 | 10 | const DAYS_7 = 1000 * 60 * 60 * 24 * 7; 11 | 12 | export function matchTransactions( 13 | transactions: MonarchTransaction[], 14 | orders: Order[], 15 | override: boolean, 16 | ): MatchedTransaction[] { 17 | const orderTransactions = orders.flatMap(order => { 18 | return ( 19 | order.transactions?.map(transaction => { 20 | return { 21 | items: order.items, 22 | refund: transaction.refund, 23 | amount: transaction.refund ? transaction.amount : transaction.amount * -1, 24 | date: transaction.date, 25 | used: false, 26 | id: order.id, 27 | }; 28 | }) ?? [] 29 | ); 30 | }); 31 | 32 | // find monarch transactions that match amazon orders. don't allow duplicates 33 | const monarchAmazonTransactions = []; 34 | for (const monarchTransaction of transactions) { 35 | const monarchDate = new Date(monarchTransaction.date); 36 | let closestAmazon = null; 37 | let closestDistance = null; 38 | for (const amazonTransaction of orderTransactions) { 39 | // we already matched this transaction 40 | if (amazonTransaction.used) continue; 41 | 42 | const orderDate = new Date(amazonTransaction.date); 43 | if (isNaN(orderDate.getTime())) continue; 44 | 45 | // look for Monarch transactions that are within 7 days of the Amazon transaction 46 | const lower = orderDate.getTime() - DAYS_7; 47 | const upper = orderDate.getTime() + DAYS_7; 48 | const matchesDate = monarchDate.getTime() >= lower && monarchDate.getTime() <= upper; 49 | 50 | // get the closest transaction 51 | const distance = Math.abs(monarchDate.getTime() - orderDate.getTime()); 52 | if ( 53 | monarchTransaction.amount === amazonTransaction.amount && 54 | matchesDate && 55 | (closestDistance === null || distance < closestDistance) 56 | ) { 57 | closestAmazon = amazonTransaction; 58 | closestDistance = distance; 59 | } 60 | } 61 | 62 | if (closestAmazon) { 63 | // Only match if the transaction doesn't have notes 64 | if (override || !monarchTransaction.notes) { 65 | monarchAmazonTransactions.push({ 66 | monarch: monarchTransaction, 67 | amazon: closestAmazon, 68 | }); 69 | } 70 | closestAmazon.used = true; 71 | } 72 | } 73 | 74 | return monarchAmazonTransactions 75 | .map(transaction => { 76 | return { 77 | amazon: transaction.amazon, 78 | items: transaction.amazon.items, 79 | monarch: transaction.monarch, 80 | }; 81 | }) 82 | .sort((a, b) => a.monarch.id.localeCompare(b.monarch.id)); 83 | } 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monarch-amazon-sync", 3 | "version": "0.3.1", 4 | "description": "Browser extension to sync Amazon order data to Monarch transactions.", 5 | "scripts": { 6 | "build": "tsc --noEmit && vite build", 7 | "build-and-zip": "pnpm build && rm -f chrome-monarch-amazon-sync.zip && cd dist && zip -r ../chrome-monarch-amazon-sync.zip .", 8 | "build-and-zip:firefox": "pnpm build:firefox && rm -f firefox-monarch-amazon-sync.zip && cd dist && zip -r ../firefox-monarch-amazon-sync.zip .", 9 | "build:firefox": "tsc --noEmit && cross-env __FIREFOX__=true vite build", 10 | "build:watch": "cross-env __DEV__=true vite build -w --mode development", 11 | "build:firefox:watch": "cross-env __DEV__=true __FIREFOX__=true vite build -w --mode development", 12 | "build:hmr": "rollup --config utils/reload/rollup.config.mjs", 13 | "wss": "node utils/reload/initReloadServer.js", 14 | "dev": "pnpm build:hmr && (run-p wss build:watch)", 15 | "dev:firefox": "pnpm build:hmr && (run-p wss build:firefox:watch)", 16 | "lint": "eslint src --ext .ts", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write", 19 | "prepare": "husky install", 20 | "release": "dotenv release-it" 21 | }, 22 | "type": "module", 23 | "dependencies": { 24 | "cheerio": "1.0.0-rc.12", 25 | "construct-style-sheets-polyfill": "3.1.0", 26 | "csv-stringify": "^6.4.5", 27 | "flowbite-react": "^0.7.2", 28 | "promise-parallel-throttle": "^3.3.0", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-icons": "^4.12.0", 32 | "webextension-polyfill": "0.10.0" 33 | }, 34 | "devDependencies": { 35 | "@rollup/plugin-typescript": "11.1.5", 36 | "@testing-library/react": "14.0.0", 37 | "@types/chrome": "0.0.251", 38 | "@types/node": "20.8.10", 39 | "@types/react": "18.2.37", 40 | "@types/react-dom": "18.2.14", 41 | "@types/ws": "8.5.8", 42 | "@typescript-eslint/eslint-plugin": "6.10.0", 43 | "@typescript-eslint/parser": "6.9.1", 44 | "@vitejs/plugin-react": "4.2.0", 45 | "autoprefixer": "^10.4.17", 46 | "chokidar": "3.5.3", 47 | "cross-env": "7.0.3", 48 | "dotenv-cli": "^7.3.0", 49 | "eslint": "8.53.0", 50 | "eslint-config-airbnb-typescript": "17.1.0", 51 | "eslint-config-prettier": "9.0.0", 52 | "eslint-plugin-import": "2.29.0", 53 | "eslint-plugin-jsx-a11y": "6.8.0", 54 | "eslint-plugin-prettier": "5.0.1", 55 | "eslint-plugin-react": "7.33.2", 56 | "eslint-plugin-react-hooks": "4.6.0", 57 | "fs-extra": "11.1.1", 58 | "husky": "8.0.3", 59 | "jsdom": "^22.1.0", 60 | "lint-staged": "15.0.2", 61 | "npm-run-all": "4.1.5", 62 | "postcss": "^8.4.33", 63 | "prettier": "3.1.0", 64 | "release-it": "^17.0.1", 65 | "rollup": "4.3.0", 66 | "sass": "1.69.5", 67 | "tailwindcss": "^3.4.1", 68 | "ts-loader": "9.5.0", 69 | "tslib": "2.6.2", 70 | "typescript": "5.2.2", 71 | "vite": "5.0.0", 72 | "vitest": "^0.34.6", 73 | "ws": "8.14.2" 74 | }, 75 | "lint-staged": { 76 | "*.{js,jsx,ts,tsx}": [ 77 | "prettier --write", 78 | "eslint --fix" 79 | ] 80 | }, 81 | "packageManager": "pnpm@8.9.2" 82 | } 83 | -------------------------------------------------------------------------------- /src/pages/popup/Options.tsx: -------------------------------------------------------------------------------- 1 | import useStorage from '@root/src/shared/hooks/useStorage'; 2 | import appStorage, { AuthStatus } from '@root/src/shared/storages/appStorage'; 3 | import debugStorage from '@root/src/shared/storages/debugStorage'; 4 | import { Label, TextInput, ToggleSwitch } from 'flowbite-react'; 5 | import { useCallback, useEffect } from 'react'; 6 | 7 | export function Options() { 8 | const { options } = useStorage(appStorage); 9 | const { logs } = useStorage(debugStorage); 10 | 11 | const downloadDebugLog = useCallback(() => { 12 | const errorString = logs.join('\n'); 13 | const blob = new Blob([errorString], { type: 'text/plain' }); 14 | const url = URL.createObjectURL(blob); 15 | chrome.downloads.download({ 16 | url: url, 17 | filename: 'error-dump.txt', 18 | }); 19 | }, [logs]); 20 | 21 | const resetMonarchStatus = useCallback(async () => { 22 | await appStorage.patch({ 23 | monarchKey: undefined, 24 | lastMonarchAuth: undefined, 25 | monarchStatus: AuthStatus.NotLoggedIn, 26 | }); 27 | }, []); 28 | 29 | const resetAmazonStatus = useCallback(async () => { 30 | await appStorage.patch({ amazonStatus: AuthStatus.NotLoggedIn }); 31 | }, []); 32 | 33 | useEffect(() => { 34 | if (!options) { 35 | appStorage.patch({ options: { overrideTransactions: false, syncEnabled: false, amazonMerchant: 'Amazon' } }); 36 | } 37 | }, [options]); 38 | 39 | if (!options) { 40 | return null; 41 | } 42 | 43 | return ( 44 |
45 |
46 |
48 | { 55 | appStorage.patch({ options: { ...options, amazonMerchant: element.target.value } }); 56 | }} 57 | /> 58 |
59 | { 63 | appStorage.patch({ options: { ...options, overrideTransactions: value } }); 64 | }} 65 | /> 66 | 67 | If you have already added notes to your Amazon transactions, you can choose to override them with the the item 68 | name if it does not already match. 69 | 70 |
71 | 72 | {logs && logs.length > 0 && ( 73 |
74 | 77 |
78 | )} 79 | 80 |
81 | 84 | 85 | If GraphQL requests to Monarch API fail, the extension cached an expired token. You must log out from Monarch, 86 | reset the connection status using this button, and log in again. 87 | 88 |
89 |
90 | 93 |
94 |
95 | ); 96 | } 97 | 98 | export default Options; 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

Monarch / Amazon Sync

4 |
5 | 6 | ## What is this? 7 | 8 | A simple Chrome extension to sync Amazon purchases with [Monarch](https://monarchmoney.com) transactions. Transactions in Monarch that match the time and amount of an Amazon purchase will have a note created in Monarch with the Amazon item details. 9 | 10 | This will allow easy categorization of Amazon purchases in Monarch without the need to go back and forth from Amazon to Monarch figure out what you bought. 11 | 12 | ## Features 13 | 14 | - Automatically matches Amazon orders with Monarch transactions based on amounts and dates 15 | - Populates Monarch transaction notes with a list of item names and per-item prices 16 | - Handles refunds (adds the same item names to a refund transaction when a refund is made) 17 | - Supports gift card transactions (will match to existing Monarch transactions, does not create new transactions) 18 | - Performs a daily sync to pull new Amazon orders and match them to Monarch transactions (requires browser to be open) 19 | - Supports backfilling past years of Amazon orders to existing Monarch transactions 20 | 21 | ## Installation 22 | 23 | > [!WARNING] 24 | > This should be considered a BETA and therefore I have made the decision to not release it to the Chrome store yet. I've tested it pretty well but it may cause untold harm to your Monarch transactions! I recommend downloading a copy of your Monarch transactions before using this! 25 | 26 | 1. Download the latest release zip (`chrome-monarch-amazon-sync.zip`) from the [releases page](https://github.com/alex-peck/monarch-amazon-sync/releases/latest) 27 | 2. Unzip the file 28 | 3. Open Chrome and navigate to `chrome://extensions` 29 | 4. Enable developer mode 30 | 5. Click "Load unpacked" and select the unzipped folder 31 | 32 | ## How to use 33 | 34 | 1. Once the extension is installed, it will check if you are logged in to your Amazon account. Make sure you are logged in! 35 | 2. Open Monarch in your browser. This will allow the extension to grab the necessary API key from the page. After that you shouldn't need to keep the page open. 36 | 37 | ### Daily sync 38 | 1. Turn on "Sync" 39 | 2. Every day, the extension will check for new Amazon purchases and sync them to Monarch. 40 | 3. Optionally, use "Force sync" to manually sync purchases. 41 | 42 | ### Backfill 43 | 1. Choose "Manual backfill" 44 | 2. Pick a year to backfill 45 | 3. Optionally run in "dry-run" mode to create a CSV of what changes will be made before actually making them. 46 | 47 | ## Known limitations 48 | - The extension does not create new transactions. It only updates the notes of existing transactions. 49 | - Occasionally Amazon will break up a single order of many items into separate credit card transactions. 50 | In this case, it is not currently possible to tell which items belong to which transaction. 51 | To handle this, this extension will always populate all items in an order on every Monarch transaction associated with that Amazon order. 52 | - For the per-item amounts in each note, the amount is not including tax. There is not currently a way to get the amount of individual items including tax. 53 | 54 | ## Screenshots 55 | image 56 | 57 | ## Contributions 58 | 59 | This repo isn't currently setup very well for contributions. Feel free to submit a PR and if there is interest I may improve the tooling. 60 | 61 | ## Misc 62 | 63 | Built off of [chrome-extension-boilerplate-react-vite](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite) 64 | -------------------------------------------------------------------------------- /src/pages/popup/Main.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { Button, ToggleSwitch } from 'flowbite-react'; 3 | import progressStorage, { ProgressPhase } from '@root/src/shared/storages/progressStorage'; 4 | import useStorage from '@root/src/shared/hooks/useStorage'; 5 | import { checkAmazonAuth } from '@root/src/shared/api/amazonApi'; 6 | import appStorage, { AuthStatus } from '@root/src/shared/storages/appStorage'; 7 | import ProgressIndicator from './components/ProgressIndicator'; 8 | import withErrorBoundary from '@root/src/shared/hoc/withErrorBoundary'; 9 | import withSuspense from '@root/src/shared/hoc/withSuspense'; 10 | import ConnectionInfo, { ConnectionStatus } from './components/ConnectionInfo'; 11 | import { useAlarm } from '@root/src/shared/hooks/useAlarm'; 12 | import { Action } from '@root/src/shared/types'; 13 | 14 | const Main = () => { 15 | const progress = useStorage(progressStorage); 16 | const appData = useStorage(appStorage); 17 | const syncAlarm = useAlarm('sync-alarm'); 18 | 19 | // If the action is ongoing for more than 15 seconds, we assume it's stuck and mark it as complete 20 | const actionOngoing = useMemo(() => { 21 | return progress.phase !== ProgressPhase.Complete && progress.phase !== ProgressPhase.Idle; 22 | }, [progress]); 23 | useEffect(() => { 24 | if (actionOngoing) { 25 | if ((progress.lastUpdated || 0) < Date.now() - 15_000) { 26 | progressStorage.patch({ 27 | phase: ProgressPhase.Complete, 28 | }); 29 | } 30 | } 31 | }, [actionOngoing, progress.lastUpdated]); 32 | 33 | const [checkedAmazon, setCheckedAmazon] = useState(false); 34 | 35 | // Check if we need to re-authenticate with Amazon 36 | useEffect(() => { 37 | if ( 38 | (appData.amazonStatus === AuthStatus.Success && 39 | new Date(appData.lastAmazonAuth).getTime() > Date.now() - 1000 * 60 * 60 * 24) || 40 | checkedAmazon 41 | ) { 42 | return; 43 | } 44 | setCheckedAmazon(true); 45 | appStorage.patch({ amazonStatus: AuthStatus.Pending }).then(() => { 46 | checkAmazonAuth().then(amazon => { 47 | if (amazon.status === AuthStatus.Success) { 48 | appStorage.patch({ 49 | amazonStatus: AuthStatus.Success, 50 | lastAmazonAuth: Date.now(), 51 | oldestAmazonYear: amazon.startingYear, 52 | }); 53 | } else { 54 | appStorage.patch({ amazonStatus: amazon.status }); 55 | } 56 | }); 57 | }); 58 | }, [appData.amazonStatus, appData.lastAmazonAuth, checkedAmazon]); 59 | 60 | const ready = 61 | appData.amazonStatus === AuthStatus.Success && appData.monarchStatus === AuthStatus.Success && !actionOngoing; 62 | 63 | const forceSync = useCallback(async () => { 64 | if (!ready) return; 65 | 66 | await chrome.runtime.sendMessage({ action: Action.FullSync }); 67 | }, [ready]); 68 | 69 | return ( 70 |
71 |
72 | 90 | 102 |
103 | 104 |
105 | 106 |
107 | 108 |
109 |
110 | { 114 | appStorage.patch({ options: { ...appData.options, syncEnabled: value } }); 115 | }} 116 | /> 117 | 118 | When enabled, sync will run automatically every 24 hours. 119 | 120 | {appData.options.syncEnabled && ( 121 | 122 | Next sync: {syncAlarm ? new Date(syncAlarm.scheduledTime).toLocaleTimeString() : '...'} 123 | 124 | )} 125 |
126 | 129 |
130 |
131 | ); 132 | }; 133 | 134 | export default withErrorBoundary(withSuspense(Main,
Loading ...
),
Error Occur
); 135 | -------------------------------------------------------------------------------- /src/pages/popup/components/ProgressIndicator.tsx: -------------------------------------------------------------------------------- 1 | import useStorage from '@root/src/shared/hooks/useStorage'; 2 | import appStorage, { mapFailureReasonToMessage } from '@root/src/shared/storages/appStorage'; 3 | import { ProgressPhase, ProgressState } from '@root/src/shared/storages/progressStorage'; 4 | import { Button, Progress, Spinner } from 'flowbite-react'; 5 | import { useCallback } from 'react'; 6 | import { FaTimesCircle } from 'react-icons/fa'; 7 | import { LuCircleSlash } from 'react-icons/lu'; 8 | import { RiCheckboxCircleFill } from 'react-icons/ri'; 9 | import { stringify } from 'csv-stringify/browser/esm/sync'; 10 | import transactionStorage from '@root/src/shared/storages/transactionStorage'; 11 | import { matchTransactions } from '@root/src/shared/api/matchUtil'; 12 | 13 | export function ProgressIndicator({ progress }: { progress: ProgressState }) { 14 | const { lastSync } = useStorage(appStorage); 15 | 16 | const lastSyncTime = lastSync ? new Date(lastSync.time).toLocaleString() : 'Never'; 17 | 18 | const dryRunDownload = useCallback(async () => { 19 | const appData = await appStorage.get(); 20 | const transactions = await transactionStorage.get(); 21 | 22 | if (!lastSync || !transactions || !lastSync?.dryRun) { 23 | return; 24 | } 25 | 26 | const matches = matchTransactions( 27 | transactions.transactions, 28 | transactions.orders, 29 | appData.options.overrideTransactions, 30 | ); 31 | const contents = matches.map(match => { 32 | return { 33 | amazonOrderId: match.amazon.id, 34 | monarchDate: match.monarch.date, 35 | amazonDate: match.amazon.date, 36 | monarchAmount: match.monarch.amount, 37 | amazonAmount: match.amazon.amount, 38 | refund: match.amazon.refund, 39 | items: match.items, 40 | }; 41 | }); 42 | 43 | const csvData = stringify(contents, { header: true }); 44 | const blob = new Blob([csvData], { type: 'text/csv' }); 45 | 46 | const url = URL.createObjectURL(blob); 47 | chrome.downloads.download({ 48 | url: url, 49 | filename: 'monarch-amazon-matches.csv', 50 | }); 51 | }, [lastSync]); 52 | 53 | const inProgress = progress.phase !== ProgressPhase.Complete && progress.phase !== ProgressPhase.Idle; 54 | return ( 55 | <> 56 | {inProgress ? ( 57 | 58 | ) : lastSync?.success && lastSync?.transactionsUpdated > 0 ? ( 59 |
60 | 61 | Last sync: {lastSyncTime} 62 | Amazon orders: {lastSync.amazonOrders} 63 | Monarch transactions: {lastSync.monarchTransactions} 64 | {lastSync.dryRun ? ( 65 |
66 | Would have updated transactions: {lastSync.transactionsUpdated} 67 | 70 |
71 | ) : ( 72 | Updated Transactions: {lastSync.transactionsUpdated} 73 | )} 74 |
75 | ) : lastSync?.success && lastSync?.transactionsUpdated == 0 ? ( 76 |
77 | 78 | Last sync: {lastSyncTime} 79 | Amazon orders: {lastSync.amazonOrders} 80 | Monarch transactions: {lastSync.monarchTransactions} 81 | No transactions to update 82 |
83 | ) : lastSync?.success === false ? ( 84 |
85 | 86 | Last sync: {lastSyncTime} 87 | Sync failed, please try again 88 | 89 | Failure reason: {mapFailureReasonToMessage(lastSync.failureReason)} 90 | 91 |
92 | ) : null} 93 | 94 | ); 95 | } 96 | 97 | function ProgressSpinner({ progress }: { progress: ProgressState }) { 98 | const percent = Math.ceil((100 * progress.complete) / progress.total); 99 | let phase = null; 100 | let object = null; 101 | if (progress.phase === ProgressPhase.MonarchUpload) { 102 | phase = 'Setting Monarch notes'; 103 | object = 'transactions'; 104 | } else if (progress.phase === ProgressPhase.AmazonPageScan) { 105 | phase = 'Downloading Amazon Orders'; 106 | object = 'pages'; 107 | } else if (progress.phase === ProgressPhase.AmazonOrderDownload) { 108 | phase = 'Downloading Amazon Orders'; 109 | object = 'orders'; 110 | } else { 111 | phase = 'Downloading Transactions'; 112 | object = 'transactions'; 113 | } 114 | const status = `${progress.complete} / ${progress.total} ${object}`; 115 | 116 | return ( 117 |
118 |
119 | 120 |
121 |

{phase}

122 | {progress.total > 0 && ( 123 | <> 124 | 125 |

{status}

126 | 127 | )} 128 |
129 | ); 130 | } 131 | 132 | export default ProgressIndicator; 133 | -------------------------------------------------------------------------------- /src/shared/storages/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage area type for persisting and exchanging data. 3 | * @see https://developer.chrome.com/docs/extensions/reference/storage/#overview 4 | */ 5 | export enum StorageType { 6 | /** 7 | * Persist data locally against browser restarts. Will be deleted by uninstalling the extension. 8 | * @default 9 | */ 10 | Local = 'local', 11 | /** 12 | * Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply. 13 | */ 14 | Sync = 'sync', 15 | /** 16 | * Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a 17 | * json schema for company wide config. 18 | */ 19 | Managed = 'managed', 20 | /** 21 | * Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and 22 | * therefore need to restore their state. Set {@link SessionAccessLevel} for permitting content scripts access. 23 | * @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) 24 | */ 25 | Session = 'session', 26 | } 27 | 28 | /** 29 | * Global access level requirement for the {@link StorageType.Session} Storage Area. 30 | * @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel) 31 | */ 32 | export enum SessionAccessLevel { 33 | /** 34 | * Storage can only be accessed by Extension pages (not Content scripts). 35 | * @default 36 | */ 37 | ExtensionPagesOnly = 'TRUSTED_CONTEXTS', 38 | /** 39 | * Storage can be accessed by both Extension pages and Content scripts. 40 | */ 41 | ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS', 42 | } 43 | 44 | type ValueOrUpdate = D | ((prev: D | null) => Promise | D); 45 | 46 | export type BaseStorage = { 47 | get: () => Promise; 48 | set: (value: ValueOrUpdate) => Promise; 49 | getSnapshot: () => D | null; 50 | subscribe: (listener: () => void) => () => void; 51 | patch: (value: Partial) => Promise; 52 | }; 53 | 54 | type StorageConfig = { 55 | /** 56 | * Assign the {@link StorageType} to use. 57 | * @default Local 58 | */ 59 | storageType?: StorageType; 60 | /** 61 | * Only for {@link StorageType.Session}: Grant Content scripts access to storage area? 62 | * @default false 63 | */ 64 | sessionAccessForContentScripts?: boolean; 65 | /** 66 | * Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts. 67 | * To allow chrome background scripts to stay in sync as well, use {@link StorageType.Session} storage area with 68 | * {@link StorageConfig.sessionAccessForContentScripts} potentially also set to true. 69 | * @see https://stackoverflow.com/a/75637138/2763239 70 | * @default false 71 | */ 72 | liveUpdate?: boolean; 73 | }; 74 | 75 | /** 76 | * Sets or updates an arbitrary cache with a new value or the result of an update function. 77 | */ 78 | async function updateCache(valueOrUpdate: ValueOrUpdate, cache: D | null): Promise { 79 | // Type guard to check if our value or update is a function 80 | function isFunction(value: ValueOrUpdate): value is (prev: D | null) => D | Promise { 81 | return typeof value === 'function'; 82 | } 83 | 84 | // Type guard to check in case of a function, if its a Promise 85 | function returnsPromise(func: (prev: D) => D | Promise): boolean { 86 | // Use ReturnType to infer the return type of the function and check if it's a Promise 87 | return (func as (prev: D) => Promise) instanceof Promise; 88 | } 89 | 90 | if (isFunction(valueOrUpdate)) { 91 | // Check if the function returns a Promise 92 | if (returnsPromise(valueOrUpdate)) { 93 | return await valueOrUpdate(cache); 94 | } else { 95 | return valueOrUpdate(cache); 96 | } 97 | } else { 98 | return valueOrUpdate; 99 | } 100 | } 101 | 102 | /** 103 | * If one session storage needs access from content scripts, we need to enable it globally. 104 | * @default false 105 | */ 106 | let globalSessionAccessLevelFlag: StorageConfig['sessionAccessForContentScripts'] = false; 107 | 108 | /** 109 | * Checks if the storage permission is granted in the manifest.json. 110 | */ 111 | function checkStoragePermission(storageType: StorageType): void { 112 | if (chrome.storage[storageType] === undefined) { 113 | throw new Error(`Check your storage permission in manifest.json: ${storageType} is not defined`); 114 | } 115 | } 116 | 117 | /** 118 | * Creates a storage area for persisting and exchanging data. 119 | */ 120 | export function createStorage(key: string, fallback: D, config?: StorageConfig): BaseStorage { 121 | let cache: D | null = null; 122 | let listeners: Array<() => void> = []; 123 | const storageType = config?.storageType ?? StorageType.Local; 124 | const liveUpdate = config?.liveUpdate ?? false; 125 | 126 | // Set global session storage access level for StoryType.Session, only when not already done but needed. 127 | if ( 128 | globalSessionAccessLevelFlag === false && 129 | storageType === StorageType.Session && 130 | config?.sessionAccessForContentScripts === true 131 | ) { 132 | checkStoragePermission(storageType); 133 | chrome.storage[storageType].setAccessLevel({ 134 | accessLevel: SessionAccessLevel.ExtensionPagesAndContentScripts, 135 | }); 136 | globalSessionAccessLevelFlag = true; 137 | } 138 | 139 | // Register life cycle methods 140 | const _getDataFromStorage = async (): Promise => { 141 | checkStoragePermission(storageType); 142 | const value = await chrome.storage[storageType].get([key]); 143 | return value[key] ?? fallback; 144 | }; 145 | 146 | const _emitChange = () => { 147 | listeners.forEach(listener => listener()); 148 | }; 149 | 150 | const set = async (valueOrUpdate: ValueOrUpdate) => { 151 | cache = await updateCache(valueOrUpdate, cache); 152 | 153 | await chrome.storage[storageType].set({ [key]: cache }); 154 | _emitChange(); 155 | }; 156 | 157 | const patch = (value: Partial) => { 158 | return set(prev => { 159 | if (prev === null) return value as D; 160 | return { ...prev, ...value }; 161 | }); 162 | }; 163 | 164 | const subscribe = (listener: () => void) => { 165 | listeners = [...listeners, listener]; 166 | return () => { 167 | listeners = listeners.filter(l => l !== listener); 168 | }; 169 | }; 170 | 171 | const getSnapshot = () => { 172 | return cache; 173 | }; 174 | 175 | _getDataFromStorage().then(data => { 176 | cache = data; 177 | _emitChange(); 178 | }); 179 | 180 | // Listener for live updates from the browser 181 | async function _updateFromStorageOnChanged(changes: { [key: string]: chrome.storage.StorageChange }) { 182 | // Check if the key we are listening for is in the changes object 183 | if (changes[key] === undefined) return; 184 | 185 | const valueOrUpdate: ValueOrUpdate = changes[key].newValue; 186 | 187 | if (cache === valueOrUpdate) return; 188 | 189 | cache = await updateCache(valueOrUpdate, cache); 190 | 191 | _emitChange(); 192 | } 193 | 194 | // Register listener for live updates for our storage area 195 | if (liveUpdate) { 196 | chrome.storage[storageType].onChanged.addListener(_updateFromStorageOnChanged); 197 | } 198 | 199 | return { 200 | get: _getDataFromStorage, 201 | set, 202 | getSnapshot, 203 | subscribe, 204 | patch, 205 | }; 206 | } 207 | -------------------------------------------------------------------------------- /src/shared/api/amazonApi.ts: -------------------------------------------------------------------------------- 1 | import { ProgressPhase, updateProgress } from '../storages/progressStorage'; 2 | import { load } from 'cheerio'; 3 | import type { CheerioAPI } from 'cheerio'; 4 | import * as Throttle from 'promise-parallel-throttle'; 5 | import { debugLog } from '../storages/debugStorage'; 6 | import { AuthStatus } from '../storages/appStorage'; 7 | 8 | const ORDER_PAGES_URL = 'https://www.amazon.com/gp/css/order-history?disableCsd=no-js'; 9 | const ORDER_RETURNS_URL = 'https://www.amazon.com/spr/returns/cart'; 10 | 11 | const ORDER_INVOICE_URL = 'https://www.amazon.com/gp/css/summary/print.html'; 12 | 13 | export type AmazonInfo = { 14 | status: AuthStatus; 15 | startingYear?: number; 16 | }; 17 | 18 | // Orders are placed on a single date, but can be paid for with multiple transactions 19 | export type Order = { 20 | id: string; 21 | date: string; 22 | items: Item[]; 23 | transactions: OrderTransaction[]; 24 | }; 25 | 26 | export type Item = { 27 | quantity: number; 28 | title: string; 29 | price: number; 30 | }; 31 | 32 | export type OrderTransaction = { 33 | id: string; 34 | amount: number; 35 | date: string; 36 | refund: boolean; 37 | }; 38 | 39 | export async function checkAmazonAuth(): Promise { 40 | try { 41 | debugLog('Checking Amazon auth'); 42 | const res = await fetch(ORDER_PAGES_URL); 43 | await debugLog('Got Amazon auth response' + res.status); 44 | const text = await res.text(); 45 | const $ = load(text); 46 | 47 | const signIn = $('h1:contains("Sign in")'); 48 | 49 | if (signIn.length > 0) { 50 | await debugLog('Amazon auth failed'); 51 | return { 52 | status: AuthStatus.NotLoggedIn, 53 | }; 54 | } 55 | 56 | const yearOptions: string[] = []; 57 | $('#time-filter') 58 | .find('option') 59 | .each((_, el) => { 60 | if ($(el).attr('value')?.includes('year')) { 61 | yearOptions.push(el.attribs.value?.trim().replace('year-', '')); 62 | } 63 | }); 64 | // find the lowest year 65 | const lowestYear = Math.min(...yearOptions.map(x => parseInt(x))); 66 | 67 | await debugLog('Amazon auth success'); 68 | return { 69 | status: AuthStatus.Success, 70 | startingYear: lowestYear, 71 | }; 72 | } catch (e) { 73 | await debugLog('Amazon auth failed with error: ' + e); 74 | return { 75 | status: AuthStatus.Failure, 76 | }; 77 | } 78 | } 79 | 80 | export async function fetchOrders(year: number | undefined): Promise { 81 | let url = ORDER_PAGES_URL; 82 | if (year) { 83 | url += `&timeFilter=year-${year}`; 84 | } 85 | await debugLog('Fetching orders from ' + url); 86 | const res = await fetch(url); 87 | await debugLog('Got orders response ' + res.status); 88 | const text = await res.text(); 89 | const $ = load(text); 90 | 91 | let endPage = 1; 92 | $('.a-pagination li').each((_, el) => { 93 | const page = $(el).text().trim(); 94 | if (!Number.isNaN(page)) { 95 | const numPage = parseInt(page); 96 | if (numPage > endPage) { 97 | endPage = numPage; 98 | } 99 | } 100 | }); 101 | 102 | await updateProgress(ProgressPhase.AmazonPageScan, endPage, 0); 103 | 104 | let orderCards = orderCardsFromPage($); 105 | await debugLog('Found ' + orderCards.length + ' orders'); 106 | 107 | await updateProgress(ProgressPhase.AmazonPageScan, endPage, 1); 108 | 109 | for (let i = 2; i <= endPage; i++) { 110 | const ordersPage = await processOrders(year, i); 111 | orderCards = orderCards.concat(ordersPage); 112 | await updateProgress(ProgressPhase.AmazonPageScan, endPage, i); 113 | } 114 | 115 | const allOrders: Order[] = []; 116 | 117 | const processOrder = async (orderCard: OrderCard) => { 118 | try { 119 | const orderData = await fetchOrderDataFromInvoice(orderCard.id); 120 | if (orderCard.hasRefund) { 121 | const refundData = await fetchRefundTransactions(orderCard.id); 122 | if (refundData) { 123 | orderData.transactions = orderData.transactions.concat(refundData); 124 | } 125 | } 126 | if (orderData) { 127 | allOrders.push(orderData); 128 | } 129 | } catch (e: unknown) { 130 | await debugLog(e); 131 | } 132 | 133 | await updateProgress(ProgressPhase.AmazonOrderDownload, orderCards.length, allOrders.length); 134 | }; 135 | 136 | await Throttle.all(orderCards.map(orderCard => () => processOrder(orderCard))); 137 | 138 | console.log(allOrders); 139 | 140 | return allOrders; 141 | } 142 | 143 | async function processOrders(year: number | undefined, page: number) { 144 | const index = (page - 1) * 10; 145 | let url = ORDER_PAGES_URL + '&startIndex=' + index; 146 | if (year) { 147 | url += `&timeFilter=year-${year}`; 148 | } 149 | await debugLog('Fetching orders from ' + url); 150 | const res = await fetch(url); 151 | await debugLog('Got orders response ' + res.status + ' for page ' + page); 152 | const text = await res.text(); 153 | const $ = load(text); 154 | return orderCardsFromPage($); 155 | } 156 | 157 | type OrderCard = { 158 | id: string; 159 | hasRefund: boolean; 160 | }; 161 | 162 | // Returns a list of order IDs on the page and whether the order contains a refund 163 | function orderCardsFromPage($: CheerioAPI): OrderCard[] { 164 | const orders: OrderCard[] = []; 165 | $('.js-order-card').each((_, el) => { 166 | try { 167 | const id = $(el) 168 | .find('a[href*="orderID="]') 169 | ?.attr('href') 170 | ?.replace(/.*orderID=([^&#]+).*/, '$1'); 171 | if (id) { 172 | const hasRefund = $(el).find('span:contains("Return complete"), span:contains("Refunded")').length > 0; 173 | orders.push({ id, hasRefund }); 174 | } 175 | } catch (e: unknown) { 176 | debugLog(e); 177 | } 178 | }); 179 | return orders; 180 | } 181 | 182 | async function fetchRefundTransactions(orderId: string): Promise { 183 | await debugLog('Fetching order details ' + orderId); 184 | const res = await fetch(ORDER_RETURNS_URL + '?orderID=' + orderId); 185 | await debugLog('Got order invoice response ' + res.status + ' for order ' + orderId); 186 | const text = await res.text(); 187 | const $ = load(text); 188 | 189 | // TODO: We can parse out individual refunded items here 190 | const transactions: OrderTransaction[] = []; 191 | $('span.a-color-secondary:contains("refund issued on")').each((_, el) => { 192 | const refundLine = $(el).text(); 193 | const refundAmount = refundLine.split('refund')[0].trim(); 194 | const refundDate = refundLine.split('on')[1].replace('.', '').trim(); 195 | transactions.push({ 196 | id: orderId, 197 | date: refundDate, 198 | amount: moneyToNumber(refundAmount), 199 | refund: true, 200 | }); 201 | }); 202 | 203 | return transactions; 204 | } 205 | 206 | async function fetchOrderDataFromInvoice(orderId: string): Promise { 207 | await debugLog('Fetching order invoice ' + orderId); 208 | const res = await fetch(ORDER_INVOICE_URL + '?orderID=' + orderId); 209 | await debugLog('Got order invoice response ' + res.status + ' for order ' + orderId); 210 | const text = await res.text(); 211 | const $ = load(text); 212 | 213 | const date = $('td b:contains("Order Placed:")') 214 | .parent() 215 | .contents() 216 | .filter(function () { 217 | return this.type === 'text'; 218 | }) 219 | .text() 220 | .trim(); 221 | 222 | const order = { 223 | id: orderId, 224 | date: date, 225 | }; 226 | console.log(order); 227 | 228 | const items: Item[] = []; 229 | const transactions: OrderTransaction[] = []; 230 | 231 | // Find the items ordered section and parse the items 232 | // Orders can span multiple tables by order date 233 | $('#pos_view_section:contains("Items Ordered")') 234 | .find('table') 235 | .find('table') 236 | .find('table') 237 | .find('table') 238 | .each((i, table) => { 239 | $(table) 240 | .find('tbody tr') 241 | .each((j, tr) => { 242 | // Ignore first line as it's the header 243 | if (j === 0) { 244 | return; 245 | } 246 | 247 | const quantity = $(tr) 248 | .find('td') 249 | .eq(0) 250 | .contents() 251 | .filter(function () { 252 | return this.type === 'text'; 253 | }) 254 | .text() 255 | .replace('of:', '') 256 | .trim(); 257 | const item = $(tr).find('td').eq(0).find('i').text().trim(); 258 | const price = $(tr).find('td').eq(1).text().trim(); 259 | if (item && price) { 260 | items.push({ 261 | quantity: parseInt(quantity), 262 | title: item, 263 | price: moneyToNumber(price), 264 | }); 265 | } 266 | }); 267 | }); 268 | 269 | // Find any gift card transactions 270 | const giftCardAmount = moneyToNumber($('td:contains("Gift Card Amount")').siblings().last().text()); 271 | if (giftCardAmount) { 272 | transactions.push({ 273 | id: orderId, 274 | date: order.date, 275 | amount: giftCardAmount * -1, 276 | refund: false, 277 | }); 278 | } 279 | 280 | // Find the transaction total - a single order can span multiple transactions 281 | $("div:contains('Credit Card transactions')") 282 | .parent() 283 | .siblings() 284 | .last() 285 | .find('tr') 286 | .each((i, tr) => { 287 | const transactionDate = $(tr).find('td:first').text().trim().split(':')[1].replace(':', '').trim(); 288 | const total = $(tr).find('td:last').text().trim(); 289 | transactions.push({ 290 | id: orderId, 291 | amount: moneyToNumber(total), 292 | date: transactionDate, 293 | refund: false, 294 | }); 295 | }); 296 | 297 | return { 298 | ...order, 299 | transactions, 300 | items, 301 | }; 302 | } 303 | 304 | export function moneyToNumber(money: string, absoluteValue = true) { 305 | return parseFloat(money?.replace(absoluteValue ? /[$\s-]/g : /[$\s]/g, '')); 306 | } 307 | -------------------------------------------------------------------------------- /src/pages/background/index.ts: -------------------------------------------------------------------------------- 1 | import { Order, fetchOrders } from '@root/src/shared/api/amazonApi'; 2 | import reloadOnUpdate from 'virtual:reload-on-update-in-background-script'; 3 | import 'webextension-polyfill'; 4 | import { MonarchTransaction, getTransactions, updateMonarchTransaction } from '@root/src/shared/api/monarchApi'; 5 | import progressStorage, { ProgressPhase, updateProgress } from '@root/src/shared/storages/progressStorage'; 6 | import transactionStorage, { TransactionStatus } from '@root/src/shared/storages/transactionStorage'; 7 | import { matchTransactions } from '@root/src/shared/api/matchUtil'; 8 | import appStorage, { AuthStatus, FailureReason, LastSync } from '@root/src/shared/storages/appStorage'; 9 | import { Action } from '@root/src/shared/types'; 10 | import debugStorage, { debugLog } from '@root/src/shared/storages/debugStorage'; 11 | 12 | reloadOnUpdate('pages/background'); 13 | 14 | async function checkAlarm() { 15 | const alarm = await chrome.alarms.get('sync-alarm'); 16 | 17 | if (!alarm) { 18 | const { lastSync } = await appStorage.get(); 19 | const lastTime = new Date(lastSync?.time || 0); 20 | const sinceLastSync = Date.now() - lastTime.getTime() / (1000 * 60); 21 | const delayInMinutes = Math.max(0, 24 * 60 - sinceLastSync); 22 | 23 | await chrome.alarms.create('sync-alarm', { 24 | delayInMinutes: delayInMinutes, 25 | periodInMinutes: 24 * 60, 26 | }); 27 | } 28 | } 29 | 30 | // Setup alarms for syncing 31 | checkAlarm(); 32 | chrome.alarms.onAlarm.addListener(async alarm => { 33 | if (alarm.name === 'sync-alarm') { 34 | const { amazonStatus, monarchStatus, options } = await appStorage.get(); 35 | if (options.syncEnabled && amazonStatus === AuthStatus.Success && monarchStatus === AuthStatus.Success) { 36 | await handleFullSync(undefined, () => {}); 37 | } 38 | } 39 | }); 40 | 41 | // Repopulate Monarch key when the tab is visited and the user is logged in 42 | chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { 43 | if (tab?.url?.startsWith('chrome://')) { 44 | return true; 45 | } 46 | if (changeInfo.url) { 47 | const url = new URL(changeInfo.url); 48 | if (url.hostname === 'app.monarchmoney.com') { 49 | const appData = await appStorage.get(); 50 | const lastAuth = new Date(appData.lastMonarchAuth); 51 | if ( 52 | !appData.monarchKey || 53 | appData.monarchStatus !== AuthStatus.Success || 54 | lastAuth < new Date(Date.now() - 1000 * 60 * 60 * 24 * 7) 55 | ) { 56 | // Execute script in the current tab 57 | const result = await chrome.scripting.executeScript({ 58 | target: { tabId: tabId }, 59 | func: () => localStorage['persist:root'], 60 | }); 61 | try { 62 | const key = JSON.parse(JSON.parse(result[0].result).user).token; 63 | if (key) { 64 | await appStorage.patch({ monarchKey: key, lastMonarchAuth: Date.now(), monarchStatus: AuthStatus.Success }); 65 | } else { 66 | await appStorage.patch({ monarchStatus: AuthStatus.NotLoggedIn }); 67 | } 68 | } catch (ex) { 69 | await appStorage.patch({ monarchStatus: AuthStatus.Failure }); 70 | debugLog(ex); 71 | } 72 | } 73 | } 74 | } 75 | }); 76 | 77 | type Payload = { 78 | year?: string; 79 | }; 80 | 81 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 82 | if (sender.tab?.url?.startsWith('chrome://')) { 83 | return true; 84 | } 85 | 86 | if (message.action === Action.DryRun) { 87 | handleDryRun(message.payload, sendResponse); 88 | } else if (message.action === Action.FullSync) { 89 | handleFullSync(message.payload, sendResponse); 90 | } else { 91 | console.warn(`Unknown action: ${message.action}`); 92 | } 93 | 94 | return true; // indicates we will send a response asynchronously 95 | }); 96 | 97 | async function inProgress() { 98 | const progress = await progressStorage.get(); 99 | return progress.phase !== ProgressPhase.Complete && progress.phase !== ProgressPhase.Idle; 100 | } 101 | 102 | async function handleDryRun(payload: Payload | undefined, sendResponse: (args: unknown) => void) { 103 | if (await inProgress()) { 104 | sendResponse({ success: false }); 105 | return; 106 | } 107 | if (await downloadAndStoreTransactions(payload?.year, true)) { 108 | sendResponse({ success: true }); 109 | return; 110 | } 111 | sendResponse({ success: false }); 112 | } 113 | 114 | async function handleFullSync(payload: Payload | undefined, sendResponse: (args: unknown) => void) { 115 | if (await inProgress()) { 116 | sendResponse({ success: false }); 117 | return; 118 | } 119 | if (await downloadAndStoreTransactions(payload?.year, false)) { 120 | if (await updateMonarchTransactions()) { 121 | sendResponse({ success: true }); 122 | return; 123 | } 124 | } 125 | sendResponse({ success: false }); 126 | } 127 | 128 | async function logSyncComplete(payload: Partial) { 129 | await debugLog('Sync complete'); 130 | await progressStorage.patch({ phase: ProgressPhase.Complete }); 131 | await appStorage.patch({ 132 | lastSync: { 133 | time: Date.now(), 134 | amazonOrders: payload.amazonOrders ?? 0, 135 | monarchTransactions: payload.monarchTransactions ?? 0, 136 | transactionsUpdated: payload.transactionsUpdated ?? 0, 137 | success: payload.success ?? false, 138 | failureReason: payload.failureReason, 139 | dryRun: payload.dryRun ?? false, 140 | }, 141 | }); 142 | } 143 | 144 | async function downloadAndStoreTransactions(yearString?: string, dryRun: boolean = false) { 145 | await debugStorage.set({ logs: [] }); 146 | 147 | const appData = await appStorage.get(); 148 | const year = yearString ? parseInt(yearString) : undefined; 149 | 150 | if (!appData.monarchKey) { 151 | await logSyncComplete({ success: false, failureReason: FailureReason.NoMonarchAuth }); 152 | return false; 153 | } 154 | 155 | await updateProgress(ProgressPhase.AmazonPageScan, 0, 0); 156 | 157 | let orders: Order[]; 158 | try { 159 | await debugLog('Fetching Amazon orders'); 160 | orders = await fetchOrders(year); 161 | } catch (e) { 162 | await debugLog(e); 163 | await logSyncComplete({ success: false, failureReason: FailureReason.AmazonError }); 164 | return false; 165 | } 166 | 167 | if (!orders || orders.length === 0) { 168 | await debugLog('No Amazon orders found'); 169 | await logSyncComplete({ success: false, failureReason: FailureReason.NoAmazonOrders }); 170 | return false; 171 | } 172 | await transactionStorage.patch({ 173 | orders: orders, 174 | }); 175 | 176 | await progressStorage.patch({ phase: ProgressPhase.MonarchDownload, total: 1, complete: 0 }); 177 | 178 | let startDate: Date; 179 | let endDate: Date; 180 | if (year) { 181 | startDate = new Date(year - 1, 11, 23); 182 | endDate = new Date(year + 1, 0, 8); 183 | } else { 184 | startDate = new Date(); 185 | startDate.setMonth(startDate.getMonth() - 3); 186 | startDate.setDate(startDate.getDate() - 8); 187 | endDate = new Date(); 188 | endDate.setDate(startDate.getDate() + 8); 189 | } 190 | 191 | let monarchTransactions: MonarchTransaction[]; 192 | try { 193 | await debugLog('Fetching Monarch transactions'); 194 | monarchTransactions = await getTransactions(appData.monarchKey, appData.options.amazonMerchant, startDate, endDate); 195 | if (!monarchTransactions || monarchTransactions.length === 0) { 196 | await logSyncComplete({ success: false, failureReason: FailureReason.NoMonarchTransactions }); 197 | return false; 198 | } 199 | } catch (ex) { 200 | await debugLog(ex); 201 | await logSyncComplete({ success: false, failureReason: FailureReason.MonarchError }); 202 | return false; 203 | } 204 | 205 | await transactionStorage.patch({ 206 | result: TransactionStatus.Success, 207 | transactions: monarchTransactions, 208 | }); 209 | 210 | if (dryRun) { 211 | const matches = matchTransactions(monarchTransactions, orders, appData.options.overrideTransactions); 212 | await logSyncComplete({ 213 | success: true, 214 | dryRun: true, 215 | amazonOrders: orders.length, 216 | monarchTransactions: monarchTransactions.length, 217 | transactionsUpdated: matches.length, 218 | }); 219 | return true; 220 | } 221 | 222 | return true; 223 | } 224 | 225 | async function updateMonarchTransactions() { 226 | await progressStorage.patch({ phase: ProgressPhase.MonarchUpload, total: 0, complete: 0 }); 227 | 228 | const transactions = await transactionStorage.get(); 229 | const appData = await appStorage.get(); 230 | 231 | if (!appData.monarchKey) { 232 | await logSyncComplete({ 233 | success: false, 234 | failureReason: FailureReason.NoMonarchAuth, 235 | amazonOrders: transactions.orders.length, 236 | monarchTransactions: transactions.transactions.length, 237 | }); 238 | return false; 239 | } 240 | 241 | const matches = matchTransactions( 242 | transactions.transactions, 243 | transactions.orders, 244 | appData.options.overrideTransactions, 245 | ); 246 | 247 | for (const data of matches) { 248 | const itemString = data.items 249 | .map(item => { 250 | return item.quantity + 'x ' + item.title + ' - $' + item.price.toFixed(2); 251 | }) 252 | .join('\n\n') 253 | .trim(); 254 | if (itemString.length === 0) { 255 | await debugLog('No items found for transaction ' + data.monarch.id); 256 | continue; 257 | } 258 | if (data.monarch.notes === itemString) { 259 | await debugLog('Transaction ' + data.monarch.id + ' already has correct note'); 260 | continue; 261 | } 262 | 263 | updateMonarchTransaction(appData.monarchKey, data.monarch.id, itemString); 264 | await debugLog('Updated transaction ' + data.monarch.id + ' with note ' + itemString); 265 | await progressStorage.patch({ 266 | total: matches.length, 267 | complete: matches.indexOf(data) + 1, 268 | }); 269 | await new Promise(resolve => setTimeout(resolve, 500)); 270 | } 271 | 272 | await logSyncComplete({ 273 | success: true, 274 | amazonOrders: transactions.orders.length, 275 | monarchTransactions: transactions.transactions.length, 276 | transactionsUpdated: matches.length, 277 | }); 278 | await progressStorage.patch({ phase: ProgressPhase.Complete }); 279 | 280 | return true; 281 | } 282 | --------------------------------------------------------------------------------