├── .husky └── pre-commit ├── app ├── report │ ├── types.ts │ ├── _CopyableCell.tsx │ ├── page.tsx │ ├── _TaxReportBox.tsx │ ├── _ReportUs.tsx │ ├── _FractionAssignmentModal.tsx │ ├── _Report.tsx │ └── _ReportFr.tsx ├── favicon.ico ├── globals.css ├── page.tsx ├── layout.tsx └── api │ ├── euro │ └── [date] │ │ └── route.ts │ └── stock │ └── [symbol] │ └── daily │ └── route.ts ├── .prettierrc ├── .prettierignore ├── postcss.config.js ├── public ├── 2021_mc-kenzie-taxes-presentation.pdf ├── images │ └── fr-taxes │ │ ├── form-2074-page-1.png │ │ ├── comptes-a-l-etranger.png │ │ ├── foreign-account-8uu.png │ │ ├── foreign-account-form.png │ │ ├── form-2074-box-1133.png │ │ ├── etrade-account-details.png │ │ ├── select-income-no-shares.png │ │ ├── select-anexes-with-share-sales.png │ │ ├── select-income-capital-gains-only.png │ │ ├── select-anexes-with-no-share-sales.png │ │ ├── select-income-acquisition-gains-only.png │ │ └── select-income-capital-gains-and-acquisition-gains.png ├── vercel.svg └── next.svg ├── .env.local.sample ├── next.config.mjs ├── .vim └── coc-settings.json ├── lib ├── symbol-daily.types.ts ├── constants.ts ├── etrade │ ├── parse-etrade-benefits.ts │ ├── filters.ts │ ├── etrade.types.ts │ └── parse-etrade-gl.ts ├── get-adjusted-gain-loss.ts ├── format-number.ts ├── date.ts ├── format-number.test.ts ├── taxes │ ├── taxable-event-fr.ts │ ├── taxes-rules-fr.ts │ └── taxes-rules-fr.test.ts └── date.test.ts ├── .vscode └── settings.json ├── .lintstagedrc.js ├── components ├── NotImplemented.tsx ├── ui │ ├── PageLink.tsx │ ├── Link.tsx │ ├── Currency.tsx │ ├── Tooltip.tsx │ ├── Section.tsx │ ├── LoadingIndicator.tsx │ ├── Drawer.tsx │ ├── Modal.tsx │ ├── Button.tsx │ ├── MessageBox.tsx │ ├── FileInput.tsx │ ├── Select.tsx │ ├── PriceInEuro.tsx │ ├── CopyButton.tsx │ ├── ButtonGroup.tsx │ ├── Toast.tsx │ └── Field.tsx ├── Back.tsx ├── QueryProvider.tsx ├── ForkMessage.tsx ├── EtradeGainAndLossesFileInput.tsx └── TaxableEventFr.tsx ├── .gitignore ├── tailwind.config.ts ├── hooks ├── use-bank-holiday.ts ├── use-fetch-symbol-daily.ts └── use-fetch-exr.ts ├── tsconfig.json ├── jest.config.ts ├── .eslintrc.json ├── .github └── workflows │ └── node.js.yml ├── package.json └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | npm run lint 3 | -------------------------------------------------------------------------------- /app/report/types.ts: -------------------------------------------------------------------------------- 1 | export type CountryCode = "us" | "fr"; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80 4 | } 5 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .prettierignore 2 | .gitignore 3 | public/ 4 | .husky/ 5 | .next/ 6 | node_modules/ 7 | .env* 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/2021_mc-kenzie-taxes-presentation.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/2021_mc-kenzie-taxes-presentation.pdf -------------------------------------------------------------------------------- /public/images/fr-taxes/form-2074-page-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/form-2074-page-1.png -------------------------------------------------------------------------------- /public/images/fr-taxes/comptes-a-l-etranger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/comptes-a-l-etranger.png -------------------------------------------------------------------------------- /public/images/fr-taxes/foreign-account-8uu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/foreign-account-8uu.png -------------------------------------------------------------------------------- /public/images/fr-taxes/foreign-account-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/foreign-account-form.png -------------------------------------------------------------------------------- /public/images/fr-taxes/form-2074-box-1133.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/form-2074-box-1133.png -------------------------------------------------------------------------------- /public/images/fr-taxes/etrade-account-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/etrade-account-details.png -------------------------------------------------------------------------------- /public/images/fr-taxes/select-income-no-shares.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-income-no-shares.png -------------------------------------------------------------------------------- /public/images/fr-taxes/select-anexes-with-share-sales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-anexes-with-share-sales.png -------------------------------------------------------------------------------- /public/images/fr-taxes/select-income-capital-gains-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-income-capital-gains-only.png -------------------------------------------------------------------------------- /public/images/fr-taxes/select-anexes-with-no-share-sales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-anexes-with-no-share-sales.png -------------------------------------------------------------------------------- /.env.local.sample: -------------------------------------------------------------------------------- 1 | # Used to fetch market data for a given symbol. 2 | # To create a new API key, visit: https://www.alphavantage.co/support/#api-key 3 | ALPHA_VANTAGE_API_KEY="" 4 | -------------------------------------------------------------------------------- /public/images/fr-taxes/select-income-acquisition-gains-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-income-acquisition-gains-only.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | typedRoutes: true, 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.autoFixOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/images/fr-taxes/select-income-capital-gains-and-acquisition-gains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinosxz/tax-helper/HEAD/public/images/fr-taxes/select-income-capital-gains-and-acquisition-gains.png -------------------------------------------------------------------------------- /lib/symbol-daily.types.ts: -------------------------------------------------------------------------------- 1 | /** String in the form "YYYY-MM-DD" */ 2 | export type ApiDate = string; 3 | export interface SymbolDailyResponse { 4 | [date: ApiDate]: { opening: number; closing: number }; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const ONE_SECOND = 1000; 2 | export const ONE_MINUTE = 60 * ONE_SECOND; 3 | export const ONE_HOUR = 60 * ONE_MINUTE; 4 | export const ONE_DAY = 24 * ONE_HOUR; 5 | 6 | export const eTradeGainsLossesUrl = 7 | "https://us.etrade.com/etx/sp/stockplan#/myAccount/gainsLosses"; 8 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const buildEslintCommand = (filenames) => 4 | `next lint --fix --file ${filenames 5 | .map((f) => path.relative(process.cwd(), f)) 6 | .join(" --file ")}`; 7 | 8 | module.exports = { 9 | "*.{js,jsx,ts,tsx}": [buildEslintCommand], 10 | "*": "prettier --write", 11 | }; 12 | -------------------------------------------------------------------------------- /lib/etrade/parse-etrade-benefits.ts: -------------------------------------------------------------------------------- 1 | import type { BenefitHistoryEvent } from "./etrade.types"; 2 | 3 | export const parseEtradeBenefits = ( 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | file: File, 6 | ): Promise => { 7 | // Parsing of benefits is a level higher than parsing of gains and losses. 8 | // let's keep it for later. 9 | throw new Error("Not implemented"); 10 | }; 11 | -------------------------------------------------------------------------------- /components/NotImplemented.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@/components/ui/Link"; 2 | 3 | const GITHUB_REPO_URL = "https://github.com/hinosxz/tax-helper"; 4 | 5 | export default function NotImplemented() { 6 | return ( 7 |
8 | not implemented yet, feel free to contribute to{" "} 9 | 10 | {" "} 11 | the repository 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/report/_CopyableCell.tsx: -------------------------------------------------------------------------------- 1 | import { CopyButton } from "@/components/ui/CopyButton"; 2 | 3 | export const CopyableCell: React.FunctionComponent<{ 4 | value: string | number; 5 | }> = ({ value }) => { 6 | return ( 7 |
8 | 9 | {value} 10 | 11 | 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /components/ui/PageLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function PageLink({ 4 | children, 5 | href, 6 | }: { 7 | children: React.ReactNode; 8 | href: string; 9 | }) { 10 | return ( 11 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/ui/Link.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | interface LinkProps { 4 | children: ReactNode; 5 | href: string; 6 | isExternal?: boolean; 7 | } 8 | 9 | export const Link = ({ href, children, isExternal }: LinkProps) => ( 10 | 11 | 16 | {children} 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | body { 12 | color: rgb(var(--foreground-rgb)); 13 | background: linear-gradient( 14 | to bottom, 15 | transparent, 16 | rgb(var(--background-end-rgb)) 17 | ) 18 | rgb(var(--background-start-rgb)); 19 | } 20 | 21 | @layer utilities { 22 | .text-balance { 23 | text-wrap: balance; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /components/ui/Currency.tsx: -------------------------------------------------------------------------------- 1 | import { formatNumber } from "@/lib/format-number"; 2 | 3 | interface CurrencyProps { 4 | value: number | null; 5 | unit: "eur" | "usd"; 6 | /** Number of decimal places to display, default 2 */ 7 | precision?: number; 8 | } 9 | 10 | export const Currency = ({ value, unit, precision = 2 }: CurrencyProps) => { 11 | const formattedValue = formatNumber(value, precision); 12 | const unitSymbol = unit === "usd" ? "$" : "€"; 13 | return ( 14 | 15 | {formattedValue} {unitSymbol} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/etrade/filters.ts: -------------------------------------------------------------------------------- 1 | import { createEtradeGLFilter } from "@/lib/etrade/parse-etrade-gl"; 2 | 3 | export const isFrQualifiedSo = createEtradeGLFilter({ 4 | planType: "SO", 5 | qualifiedIn: "fr", 6 | }); 7 | export const isUsQualifiedSo = createEtradeGLFilter({ 8 | planType: "SO", 9 | qualifiedIn: "us", 10 | }); 11 | 12 | export const isEspp = createEtradeGLFilter({ 13 | planType: "ESPP", 14 | }); 15 | 16 | export const isFrQualifiedRsu = createEtradeGLFilter({ 17 | planType: "RS", 18 | qualifiedIn: "fr", 19 | }); 20 | export const isUsQualifiedRsu = createEtradeGLFilter({ 21 | planType: "RS", 22 | qualifiedIn: "us", 23 | }); 24 | -------------------------------------------------------------------------------- /components/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import Tippy, { type TippyProps } from "@tippyjs/react"; 2 | import "tippy.js/dist/tippy.css"; 3 | 4 | export interface TooltipProps extends Pick { 5 | /** Tooltip content */ 6 | content: React.ReactNode; 7 | /** Tooltip handle */ 8 | children: TippyProps["children"]; 9 | } 10 | 11 | /** Tooltip component based on Tippy, with default styles */ 12 | export const Tooltip: React.FunctionComponent = ({ 13 | content, 14 | children, 15 | ...tippyProps 16 | }) => { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /hooks/use-bank-holiday.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | // hook to fetch French holidays from API https://calendrier.api.gouv.fr/jours-feries/metropole.json 4 | const apiUrl = "https://calendrier.api.gouv.fr/jours-feries/metropole.json"; 5 | 6 | const fetchBankHolidays = async (): Promise<{ [date: string]: string }> => { 7 | return fetch(apiUrl) 8 | .then((res) => res.json()) 9 | .then((response) => { 10 | return response; 11 | }); 12 | }; 13 | 14 | /** 15 | * Get bank holidays since 2003. 16 | */ 17 | export const useBankHolidays = () => { 18 | return useQuery({ queryKey: ["BANK_HOLIDAYS"], queryFn: fetchBankHolidays }); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | import nextJest from "next/jest.js"; 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: "./", 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | const config: Config = { 11 | coverageProvider: "v8", 12 | testEnvironment: "jsdom", 13 | // Add more setup options before each test is run 14 | // setupFilesAfterEnv: ['/jest.setup.ts'], 15 | }; 16 | 17 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 18 | export default createJestConfig(config); 19 | -------------------------------------------------------------------------------- /components/ui/Section.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | interface SectionProps { 4 | className?: string; 5 | title: string; 6 | children: ReactNode; 7 | actions?: ReactNode; 8 | } 9 | 10 | export const Section = ({ 11 | className, 12 | title, 13 | children, 14 | actions, 15 | }: SectionProps) => ( 16 |
17 | {actions ? ( 18 |
19 |
{title}
20 |
{actions}
21 |
22 | ) : ( 23 |
{title}
24 | )} 25 | {children} 26 |
27 | ); 28 | -------------------------------------------------------------------------------- /components/Back.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ArrowLeftIcon } from "@heroicons/react/24/solid"; 3 | import { useRouter } from "next/navigation"; 4 | import classNames from "classnames"; 5 | 6 | export interface BackProps { 7 | /** Add an extra className to Back wrapper */ 8 | className?: string; 9 | } 10 | 11 | export const Back: React.FunctionComponent = () => { 12 | const router = useRouter(); 13 | return ( 14 |
router.back()} 20 | > 21 | 22 | Previous 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /components/ui/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @stylistic/js/max-len */ 2 | 3 | /** Copied from https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/90-ring-with-bg.svg?short_path=89e51c1 */ 4 | export const LoadingIndicator = () => ( 5 | 12 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import Link from "next/link"; 3 | 4 | export default function Home() { 5 | return ( 6 |
11 |
Tax Helper
12 |
13 | 20 | Compute my French tax report 21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/ui/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const Drawer: React.FunctionComponent<{ 4 | title: React.ReactNode; 5 | children: React.ReactNode; 6 | isDefaultOpen?: boolean; 7 | forceOpen?: boolean; 8 | }> = ({ title, children, isDefaultOpen = false, forceOpen = false }) => { 9 | const [isOpen, setIsOpen] = useState(isDefaultOpen); 10 | const showBody = isOpen || forceOpen; 11 | return ( 12 |
13 |
setIsOpen(!isOpen)} 16 | > 17 |
{title}
18 | {showBody ? "▲" : "▼"} 19 |
20 | {showBody &&
{children}
} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | QueryClient, 5 | QueryClientProvider, 6 | keepPreviousData, 7 | } from "@tanstack/react-query"; 8 | import { useMemo } from "react"; 9 | 10 | export default function QueryProvider({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | const queryClient = useMemo( 16 | () => 17 | new QueryClient({ 18 | defaultOptions: { 19 | queries: { 20 | retry: 1, 21 | placeholderData: keepPreviousData, 22 | refetchOnWindowFocus: false, 23 | }, 24 | }, 25 | }), 26 | [], 27 | ); 28 | 29 | return ( 30 | {children} 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "next/core-web-vitals", 6 | "prettier" 7 | ], 8 | "plugins": ["@stylistic/js"], 9 | "rules": { 10 | "prefer-const": "error", 11 | "@stylistic/js/max-len": [ 12 | "error", 13 | { 14 | "code": 80, 15 | "tabWidth": 2, 16 | "ignoreComments": true, 17 | "ignoreStrings": true, 18 | "ignoreTemplateLiterals": true, 19 | "ignorePattern": "^import\\s.+\\sfrom\\s.+;$" 20 | } 21 | ], 22 | "@stylistic/js/quotes": ["error", "double"], 23 | "@stylistic/js/jsx-quotes": ["error", "prefer-double"], 24 | "@stylistic/js/semi": ["error", "always"], 25 | "react/no-unescaped-entities": 0, 26 | "@typescript-eslint/consistent-type-imports": "error" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /components/ui/Modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import classNames from "classnames"; 3 | import { type ReactNode } from "react"; 4 | import { createPortal } from "react-dom"; 5 | 6 | interface ModalProps { 7 | children: ReactNode; 8 | show: boolean; 9 | } 10 | 11 | const _Modal = ({ show, children }: ModalProps) => { 12 | return ( 13 |
23 |
24 | {children} 25 |
26 |
27 | ); 28 | }; 29 | 30 | export const Modal = (props: ModalProps) => { 31 | // Portalize by default 32 | if (typeof window !== "undefined") { 33 | return createPortal(<_Modal {...props} />, document.body); 34 | } 35 | return <_Modal {...props} />; 36 | }; 37 | -------------------------------------------------------------------------------- /lib/get-adjusted-gain-loss.ts: -------------------------------------------------------------------------------- 1 | export function getAdjustedGainLoss( 2 | quantity: number, 3 | adjustedCost: number, 4 | proceeds: number, 5 | rateAcquired: null, 6 | rateSold: null, 7 | ): number | null; 8 | export function getAdjustedGainLoss( 9 | quantity: number, 10 | adjustedCost: number, 11 | proceeds: number, 12 | rateAcquired: number, 13 | rateSold: number, 14 | ): number; 15 | export function getAdjustedGainLoss( 16 | quantity: number, 17 | adjustedCost: number, 18 | proceeds: number, 19 | rateAcquired: number | null, 20 | rateSold: number | null, 21 | ): number | null; 22 | export function getAdjustedGainLoss( 23 | quantity: number, 24 | adjustedCost: number, 25 | proceeds: number, 26 | rateAcquired: number | null, 27 | rateSold: number | null, 28 | ): number | null { 29 | if (rateAcquired && rateSold) { 30 | return ( 31 | (proceeds * quantity) / rateSold - 32 | (adjustedCost * quantity) / rateAcquired 33 | ); 34 | } 35 | return null; 36 | } 37 | -------------------------------------------------------------------------------- /app/report/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import classNames from "classnames"; 4 | import { Report } from "./_Report"; 5 | import type { Option } from "@/components/ui/ButtonGroup"; 6 | import { ButtonGroup } from "@/components/ui/ButtonGroup"; 7 | import type { CountryCode } from "./types"; 8 | 9 | export default function Page() { 10 | const [taxResidency, setTaxResidency] = useState("fr"); 11 | const options: Option[] = [ 12 | { value: "fr", label: "FR" }, 13 | { value: "us", label: "US" }, 14 | ]; 15 | 16 | return ( 17 |
18 |
19 |
Tax Report
20 |
21 |
Residency:
22 | 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [22.x, 24.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | env: 31 | ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }} 32 | - run: npm test 33 | -------------------------------------------------------------------------------- /components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import type { ReactNode } from "react"; 3 | 4 | interface ButtonProps { 5 | icon?: ({ className }: { className: string }) => ReactNode; 6 | isBorderless?: boolean; 7 | isDisabled?: boolean; 8 | label?: string; 9 | onClick: () => void; 10 | color?: "green" | "red"; 11 | } 12 | 13 | export const Button = ({ 14 | icon: Icon, 15 | isBorderless, 16 | isDisabled, 17 | onClick, 18 | color, 19 | label, 20 | }: ButtonProps) => ( 21 | 41 | ); 42 | -------------------------------------------------------------------------------- /components/ForkMessage.tsx: -------------------------------------------------------------------------------- 1 | import { MessageBox } from "@/components/ui/MessageBox"; 2 | import { Link } from "@/components/ui/Link"; 3 | import { headers } from "next/headers"; 4 | import * as React from "react"; 5 | 6 | const OFFICIAL_HOSTNAME = "tax-helper-olive.vercel.app"; 7 | const OFFICIAL_URL = `https://${OFFICIAL_HOSTNAME}/`; 8 | 9 | export const ForkMessage = () => { 10 | const headersList = headers(); 11 | const hostname = 12 | typeof window !== "undefined" 13 | ? window.location.hostname 14 | : headersList.get("host"); 15 | 16 | const isLocal = hostname?.startsWith("localhost"); 17 | 18 | if (isLocal) { 19 | return ( 20 | 21 | THIS IS A LOCAL DEV ENVIRONMENT 22 | 23 | ); 24 | } 25 | 26 | const isFork = !hostname?.startsWith(OFFICIAL_HOSTNAME); 27 | if (isFork) { 28 | return ( 29 | 30 | THIS IS A FORK, OFFICIAL TAX HELPER IS AT{" "} 31 | {OFFICIAL_URL} 32 | 33 | ); 34 | } 35 | 36 | return null; 37 | }; 38 | -------------------------------------------------------------------------------- /components/ui/MessageBox.tsx: -------------------------------------------------------------------------------- 1 | export interface MessageBoxProps { 2 | level: "info" | "success" | "warning" | "error"; 3 | title: string; 4 | children?: React.ReactNode; 5 | } 6 | 7 | const MAP_LEVEL_TO_COLOR = { 8 | info: { bg: "bg-blue-100", text: "text-blue-700", border: "border-blue-500" }, 9 | success: { 10 | bg: "bg-green-100", 11 | text: "text-green-700", 12 | border: "border-green-500", 13 | }, 14 | warning: { 15 | bg: "bg-orange-100", 16 | text: "text-orange-700", 17 | border: "border-orange-500", 18 | }, 19 | error: { bg: "bg-red-100", text: "text-red-700", border: "border-red-500" }, 20 | }; 21 | 22 | export const MessageBox: React.FunctionComponent = ({ 23 | level, 24 | title, 25 | children, 26 | }) => { 27 | return ( 28 |
32 | {title &&

{title}

} 33 | {children ?
{children}
: null} 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { Toaster } from "react-hot-toast"; 4 | 5 | import QueryProvider from "@/components/QueryProvider"; 6 | import { Back } from "@/components/Back"; 7 | import { ForkMessage } from "@/components/ForkMessage"; 8 | 9 | import "./globals.css"; 10 | 11 | const inter = Inter({ subsets: ["latin"] }); 12 | 13 | export const metadata: Metadata = { 14 | title: "Tax Helper", 15 | description: ` 16 | A simple web app to help determine how you should report your taxes 17 | to the French and US administrations. 18 | `, 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
{children}
37 |
38 |
39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/ui/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownTrayIcon } from "@heroicons/react/24/solid"; 2 | import classNames from "classnames"; 3 | 4 | interface FileInputProps { 5 | accept?: string; 6 | id: string; 7 | isDisabled?: boolean; 8 | label: string; 9 | onUpload: (file: File | undefined) => void; 10 | } 11 | 12 | export const FileInput = ({ 13 | accept, 14 | id, 15 | isDisabled, 16 | onUpload, 17 | label, 18 | }: FileInputProps) => ( 19 |
20 | 31 | { 39 | (event.target as HTMLInputElement).value = ""; 40 | }} 41 | onInput={(event) => { 42 | const file = (event.target as HTMLInputElement).files?.[0]; 43 | onUpload(file); 44 | }} 45 | /> 46 |
47 | ); 48 | -------------------------------------------------------------------------------- /components/ui/Select.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | export type SelectValues = string | number; 3 | export interface SelectProps { 4 | /** Add an extra className to Select wrapper */ 5 | className?: string; 6 | /** Label for the Select */ 7 | label: React.ReactNode; 8 | /** Options for the Select */ 9 | options: Array<{ label: string; value: T }>; 10 | /** The value of the Select */ 11 | value: T | undefined; 12 | /** Callback when the Select value changes */ 13 | onChange: (value: T) => void; 14 | /** Whether the Select is disabled */ 15 | isDisabled?: boolean; 16 | } 17 | 18 | export function Select(props: SelectProps) { 19 | return ( 20 |
26 | 27 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/EtradeGainAndLossesFileInput.tsx: -------------------------------------------------------------------------------- 1 | import { FileInput } from "@/components/ui/FileInput"; 2 | import type { GainAndLossEvent } from "@/lib/etrade/etrade.types"; 3 | import { parseEtradeGL } from "@/lib/etrade/parse-etrade-gl"; 4 | import { sendErrorToast } from "@/components/ui/Toast"; 5 | 6 | export interface EtradeGainAndLossesFileInputProps { 7 | /** Unique identifier for the input. Available for css selector as `#${id}` */ 8 | id?: string; 9 | /** Label for the input */ 10 | label?: string; 11 | /** Function called when data are read from the etrade export */ 12 | setData: (data: GainAndLossEvent[]) => void; 13 | /** Function called when an error occurs. */ 14 | handleError?: (error: string) => void; 15 | } 16 | 17 | export const EtradeGainAndLossesFileInput: React.FunctionComponent< 18 | EtradeGainAndLossesFileInputProps 19 | > = ({ 20 | id = "import_etrade_g&l", 21 | label = "Import from ETrade G&L", 22 | setData, 23 | handleError = sendErrorToast, 24 | }) => { 25 | return ( 26 | { 31 | if (!file) { 32 | return handleError("couldn't import file"); 33 | } 34 | try { 35 | const data = await parseEtradeGL(file); 36 | setData(data); 37 | } catch (e) { 38 | handleError(`Failed to import, 39 | please verify you imported the correct file.`); 40 | } 41 | }} 42 | /> 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/ui/PriceInEuro.tsx: -------------------------------------------------------------------------------- 1 | import { Currency } from "@/components/ui/Currency"; 2 | import { Tooltip } from "./Tooltip"; 3 | 4 | export interface PriceInEuroProps { 5 | /** 6 | * Optionally provide a value in EUR to use instead of the computed one. 7 | * 8 | * This is useful to avoid rounding differences when the value is computed 9 | * from USD. 10 | */ 11 | eur?: number; 12 | /** 13 | * Amount in USD, will be automatically converted if no `eur` value is 14 | * provided 15 | */ 16 | usd: number; 17 | /** Conversion rate from USD to EUR: 1 USD = rate EUR */ 18 | rate: number; 19 | /** Date of the conversion rate */ 20 | date: string; 21 | /** Number of decimal places to display, default 2 */ 22 | precision?: number; 23 | } 24 | 25 | export const PriceInEuro: React.FunctionComponent = ({ 26 | eur, 27 | usd, 28 | rate, 29 | date, 30 | precision = 2, 31 | }) => { 32 | const priceInEuro = eur ?? usd / rate; 33 | 34 | return ( 35 | 38 | at {rate} on{" "} 39 | {date} 40 | 41 | } 42 | > 43 | 44 | 45 | 46 | {" "} 47 | ( at {rate}{" "} 48 | $/€ on {date}) 49 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /components/ui/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardIcon, CheckIcon } from "@heroicons/react/24/solid"; 2 | import { useState } from "react"; 3 | import classNames from "classnames"; 4 | import Tippy from "@tippyjs/react"; 5 | 6 | interface CopyButtonProps { 7 | value: string | number | null; 8 | } 9 | 10 | export const CopyButton = ({ value }: CopyButtonProps) => { 11 | const [copied, setCopied] = useState(false); 12 | 13 | const handleCopy = () => { 14 | if (value !== null) { 15 | navigator.clipboard.writeText(value.toString()); 16 | setCopied(true); 17 | setTimeout(() => setCopied(false), 1000); 18 | } 19 | }; 20 | 21 | return ( 22 | 23 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /components/ui/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import classNames from "classnames"; 3 | 4 | export interface Option { 5 | label: string; 6 | value: V; 7 | } 8 | 9 | interface ButtonGroupProps { 10 | onClick: (value: V) => void; 11 | options: ReadonlyArray>; 12 | } 13 | 14 | export const ButtonGroup = ({ 15 | onClick, 16 | options, 17 | }: ButtonGroupProps) => { 18 | const [activeOption, setActiveOption] = useState( 19 | options.at(0)?.value, 20 | ); 21 | 22 | if (options.length < 2) { 23 | throw Error( 24 | ` component expects at least 2 options. ${options.length} passed.`, 25 | ); 26 | } 27 | return ( 28 |
29 | {options.map((option, idx) => ( 30 | 51 | ))} 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "husky", 11 | "format:all": "prettier --write .", 12 | "test": "jest", 13 | "test:watch": "jest --watch" 14 | }, 15 | "dependencies": { 16 | "@heroicons/react": "^2.1.1", 17 | "@tanstack/react-query": "^5.29.0", 18 | "@tippyjs/react": "^4.2.6", 19 | "classnames": "^2.5.1", 20 | "next": "14.1.3", 21 | "react": "^18", 22 | "react-dom": "^18", 23 | "react-hot-toast": "^2.4.1", 24 | "ts-pattern": "^5.1.1", 25 | "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" 26 | }, 27 | "devDependencies": { 28 | "@stylistic/eslint-plugin-js": "^1.6.3", 29 | "@testing-library/jest-dom": "^6.4.2", 30 | "@testing-library/react": "^15.0.5", 31 | "@types/jest": "^29.5.12", 32 | "@types/node": "^20", 33 | "@types/react": "^18", 34 | "@types/react-dom": "^18", 35 | "@typescript-eslint/eslint-plugin": "^6.21.0", 36 | "@typescript-eslint/parser": "^6.21.0", 37 | "autoprefixer": "^10.0.1", 38 | "eslint": "^8", 39 | "eslint-config-next": "14.1.3", 40 | "eslint-config-prettier": "^9.1.0", 41 | "husky": "^9.0.11", 42 | "jest": "^29.7.0", 43 | "jest-environment-jsdom": "^29.7.0", 44 | "lint-staged": "^15.2.2", 45 | "postcss": "^8", 46 | "prettier": "^3.2.5", 47 | "tailwindcss": "^3.3.0", 48 | "ts-node": "^10.9.2", 49 | "typescript": "^5" 50 | }, 51 | "volta": { 52 | "node": "22.16.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tax Helper 2 | 3 | Tax Helper is an open-source project aiming at helping French or US citizens file their French and US taxes. This tool is targeted at people holding company equity (RSU, ISO, ESPP). 4 | 5 | **DISCLAIMER:** the information shared in this app is based on the personal experience and knowledge of its contributors and most probably contains inaccurate information. Please don't take all of it for granted, verify calculations and / or confirm with a professional tax advisor if you have any doubts or questions. Pull Requests and Issues and more than welcome if you notice anything wrong about the information provided. 6 | 7 | ## Contribute 8 | 9 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 10 | 11 | ### Getting Started 12 | 13 | First, install the dependencies 14 | 15 | ```bash 16 | npm i 17 | ``` 18 | 19 | You'll then need to have an alphavantage API key to run the app. You can get one for 20 | free [here](https://www.alphavantage.co/support/#api-key). 21 | 22 | Copy the `.env.local.sample` file to `.env.local` and replace the 23 | `ALPHA_VANTAGE_API_KEY` value with your own API key. 24 | 25 | ```bash 26 | cp .env.local.sample .env.local 27 | ``` 28 | 29 | Then, run the development server: 30 | 31 | ```bash 32 | npm run dev 33 | # or 34 | yarn dev 35 | # or 36 | pnpm dev 37 | # or 38 | bun dev 39 | ``` 40 | 41 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 42 | 43 | You can start editing the app by modifying any component located in `app/`. The page auto-updates as you edit the files. 44 | -------------------------------------------------------------------------------- /hooks/use-fetch-symbol-daily.ts: -------------------------------------------------------------------------------- 1 | import { ONE_DAY } from "@/lib/constants"; 2 | import type { SymbolDailyResponse } from "@/lib/symbol-daily.types"; 3 | import { useQueries } from "@tanstack/react-query"; 4 | 5 | const apiUrl = "/api/stock/{symbol}/daily"; 6 | 7 | const fetchSymbolDaily = async ( 8 | symbol: string, 9 | ): Promise => { 10 | return fetch(`${apiUrl.replace("{symbol}", symbol)}`) 11 | .then((res) => res.json()) 12 | .then((response: SymbolDailyResponse) => { 13 | return response; 14 | }); 15 | }; 16 | 17 | interface UseSymbolDailyResponse { 18 | isFetching: boolean; 19 | isError: boolean; 20 | values: { 21 | [symbol: string]: SymbolDailyResponse; 22 | }; 23 | } 24 | 25 | /** 26 | * Get historical values for a symbol. 27 | */ 28 | export const useFetchSymbolDaily = (symbols: string[]) => { 29 | const results = useQueries({ 30 | queries: symbols.map((symbol) => ({ 31 | queryKey: ["SYMBOL_PRICES", symbol], 32 | queryFn: () => fetchSymbolDaily(symbol), 33 | staleTime: ONE_DAY, 34 | })), 35 | }); 36 | const data: UseSymbolDailyResponse = { 37 | isFetching: false, 38 | isError: false, 39 | values: {}, 40 | }; 41 | 42 | results.forEach( 43 | ( 44 | query, 45 | index /* order returned from useQueries is the same as the input order */, 46 | ) => { 47 | const symbol = symbols[index]; 48 | data.isFetching = data.isFetching || query.isFetching; 49 | data.isError = data.isError || query.isError; 50 | if (query.data) { 51 | data.values[symbol] = query.data; 52 | } 53 | }, 54 | ); 55 | 56 | return data; 57 | }; 58 | -------------------------------------------------------------------------------- /lib/format-number.ts: -------------------------------------------------------------------------------- 1 | const ExtraZerosRegex = /(\.)(\d+[1-9])?(0*)$/; 2 | const extraZeroReplacer = (_: string, p1: string, p2: string = "00") => 3 | `${p1}${p2}`; 4 | /** 5 | * Format a number with a fixes 2 decimal places. 6 | * 7 | * ``` 8 | * formatNumber(1) // "1.00" 9 | * formatNumber(1.234) // "1.23" 10 | * formatNumber(null) // "–" 11 | * ``` 12 | */ 13 | export const formatNumber = (value: number | null, precision = 2): string => { 14 | return ( 15 | value?.toFixed(precision).replace(ExtraZerosRegex, extraZeroReplacer) ?? "–" 16 | ); 17 | }; 18 | 19 | /** 20 | * Floors a number with `digits` digits. 21 | * 22 | * ``` 23 | * floorNumber(1.234) // 1.23 24 | * floorNumber(1.234, 3) // 1.234 25 | * floorNumber(1, 2) // 1 26 | * ``` 27 | */ 28 | export const floorNumber = (value: number, digits: number = 2): number => { 29 | const factor = Math.pow(10, digits); 30 | return Math.floor(value * factor) / factor; 31 | }; 32 | 33 | /** 34 | * Ceils a number with `digits` digits. 35 | * 36 | * ``` 37 | * ceilNumber(1.234) // 1.24 38 | * ceilNumber(1.234, 3) // 1.234 39 | * ceilNumber(1, 2) // 1 40 | * ``` 41 | */ 42 | export const ceilNumber = (value: number, digits: number = 2): number => { 43 | const factor = Math.pow(10, digits); 44 | return Math.ceil(value * factor) / factor; 45 | }; 46 | 47 | /** 48 | * Rounds a number with `digits` digits. 49 | * 50 | * ``` 51 | * roundNumber(1.234) // 1.23 52 | * roundNumber(1.235) // 1.24 53 | * roundNumber(1.234, 3) // 1.234 54 | * roundNumber(1, 2) // 1 55 | * ``` 56 | */ 57 | export const roundNumber = (value: number, digits: number = 2): number => { 58 | const factor = Math.pow(10, digits); 59 | return Math.round(value * factor) / factor; 60 | }; 61 | -------------------------------------------------------------------------------- /components/ui/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ExclamationTriangleIcon, 3 | InformationCircleIcon, 4 | XMarkIcon, 5 | } from "@heroicons/react/24/solid"; 6 | import classNames from "classnames"; 7 | import type { ReactNode } from "react"; 8 | import toast from "react-hot-toast"; 9 | 10 | interface ToastProps { 11 | icon?: ({ className }: { className: string }) => ReactNode; 12 | status?: "default" | "error"; 13 | message: string; 14 | isVisible: boolean; 15 | onDismiss: () => void; 16 | } 17 | 18 | export const Toast = ({ 19 | icon: LeftIcon, 20 | status = "default", 21 | message, 22 | isVisible, 23 | onDismiss, 24 | }: ToastProps) => ( 25 |
33 | {LeftIcon && ( 34 | 39 | )} 40 |
{message}
41 | 52 |
53 | ); 54 | 55 | export const sendInfoToast = (message: string) => 56 | toast.custom((t) => ( 57 | toast.remove(t.id)} 63 | /> 64 | )); 65 | 66 | export const sendErrorToast = (message: string) => 67 | toast.custom((t) => ( 68 | toast.remove(t.id)} 74 | /> 75 | )); 76 | -------------------------------------------------------------------------------- /lib/date.ts: -------------------------------------------------------------------------------- 1 | import { ONE_DAY } from "./constants"; 2 | 3 | export const getDateString = (date: Date) => 4 | date.toISOString().substring(0, 10); 5 | 6 | export const parseEtradeDate = (rawDate: string) => { 7 | const [month, day, year] = rawDate.split("/").map(Number); 8 | return new Date(Date.UTC(year, month - 1, day)); 9 | }; 10 | 11 | const months = [ 12 | "jan", 13 | "feb", 14 | "mar", 15 | "apr", 16 | "may", 17 | "jun", 18 | "jul", 19 | "aug", 20 | "sep", 21 | "oct", 22 | "nov", 23 | "dec", 24 | ]; 25 | const pattern = /^\d{4}-\d{2}-\d{2}$/; 26 | /** 27 | * Format a date of format `YYYY-MM-DD` to a French date string `DD month YYYY`. 28 | */ 29 | export const formatDateFr = (/** format YYYY-MM-DD */ date: string) => { 30 | if (!pattern.test(date)) { 31 | throw new Error("Invalid date format"); 32 | } 33 | 34 | const [year, monthNumber, day] = date.split("-").map(Number); 35 | if (monthNumber < 1 || monthNumber > 12) { 36 | throw new Error("Invalid month"); 37 | } 38 | if (day < 1 || day > 31) { 39 | throw new Error("Invalid day"); 40 | } 41 | return `${day} ${months[monthNumber - 1]} ${year}`; 42 | }; 43 | 44 | /** Check if a date of format `YYYY-MM-DD` is a Saturday or Sunday.*/ 45 | export const isWeekend = (date: string) => { 46 | if (!pattern.test(date)) { 47 | throw new Error("Invalid date format"); 48 | } 49 | const [year, month, day] = date.split("-").map(Number); 50 | const ts = Date.UTC(year, month - 1, day); 51 | // +4 because Jan 1st 1970 was a Thursday 52 | const dayOfWeek = (Math.floor(ts / ONE_DAY) + 4) % 7; 53 | return dayOfWeek === 0 || dayOfWeek === 6; 54 | }; 55 | 56 | /** 57 | * Get the day before a date. 58 | */ 59 | export const dayBefore = (/** format YYYY-MM-DD */ date: string) => { 60 | if (!pattern.test(date)) { 61 | throw new Error("Invalid date format"); 62 | } 63 | const [year, month, day] = date.split("-").map(Number); 64 | const parsedDate = new Date(Date.UTC(year, month - 1, day)); 65 | parsedDate.setDate(parsedDate.getDate() - 1); 66 | return getDateString(parsedDate); 67 | }; 68 | -------------------------------------------------------------------------------- /hooks/use-fetch-exr.ts: -------------------------------------------------------------------------------- 1 | import { ONE_DAY } from "@/lib/constants"; 2 | import type { UseQueryResult } from "@tanstack/react-query"; 3 | import { useQueries } from "@tanstack/react-query"; 4 | import { useBankHolidays } from "./use-bank-holiday"; 5 | import { dayBefore, isWeekend } from "@/lib/date"; 6 | 7 | const apiUrl = "/api/euro/{date}"; 8 | 9 | const fetchExchangeRate = async (date: string): Promise => { 10 | return fetch(`${apiUrl.replace("{date}", date)}`) 11 | .then((res) => res.json()) 12 | .then((response: number) => { 13 | return response; 14 | }); 15 | }; 16 | 17 | interface UseExchangeRateResponse { 18 | isFetching: boolean; 19 | isError: boolean; 20 | responses: { 21 | [date: string]: UseQueryResult; 22 | }; 23 | values: { 24 | [date: string]: number; 25 | }; 26 | } 27 | export const useExchangeRates = (dates: string[]): UseExchangeRateResponse => { 28 | const bankHolidays = useBankHolidays(); 29 | 30 | const results = useQueries({ 31 | queries: dates.map((date) => { 32 | // Adjust date when this is a weekend or a French bank holiday 33 | let adjustedDate = date; 34 | while (bankHolidays.data?.[adjustedDate] || isWeekend(adjustedDate)) { 35 | adjustedDate = dayBefore(adjustedDate); 36 | } 37 | return { 38 | queryKey: ["USD_EUR_EXCHANGE_RATE", date], 39 | queryFn: async () => { 40 | const rate = await fetchExchangeRate(adjustedDate); 41 | return rate; 42 | }, 43 | staleTime: ONE_DAY, 44 | enabled: !bankHolidays.isFetching, 45 | }; 46 | }), 47 | }); 48 | 49 | if (bankHolidays.isFetching) { 50 | return { 51 | isFetching: true, 52 | isError: false, 53 | responses: {}, 54 | values: {}, 55 | }; 56 | } 57 | 58 | const data: UseExchangeRateResponse = { 59 | isError: false, 60 | isFetching: false, 61 | responses: {}, 62 | values: {}, 63 | }; 64 | 65 | results.forEach((result, index) => { 66 | const { data: rate } = result; 67 | const date = dates[index]; 68 | data.isFetching = data.isFetching || result.isFetching; 69 | data.isError = data.isError || result.isError; 70 | data.responses[date] = results[index]; 71 | if (rate) { 72 | data.values[date] = rate; 73 | } 74 | }); 75 | 76 | return data; 77 | }; 78 | -------------------------------------------------------------------------------- /lib/format-number.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | floorNumber, 3 | ceilNumber, 4 | formatNumber, 5 | roundNumber, 6 | } from "./format-number"; 7 | 8 | describe("formatNumber", () => { 9 | it("should add 2 decimal places", () => { 10 | expect(formatNumber(1)).toBe("1.00"); 11 | }); 12 | it("should add 2 decimal places", () => { 13 | expect(formatNumber(1.1)).toBe("1.10"); 14 | }); 15 | it("should format a number with 2 decimal places", () => { 16 | expect(formatNumber(1.234)).toBe("1.23"); 17 | }); 18 | it("should format null", () => { 19 | expect(formatNumber(null)).toBe("–"); 20 | }); 21 | it("should accept precision", () => { 22 | expect(formatNumber(1.124, 3)).toBe("1.124"); 23 | }); 24 | it("should replace extra 0", () => { 25 | expect(formatNumber(1.12, 3)).toBe("1.12"); 26 | }); 27 | it("should replace only extra 0s`", () => { 28 | expect(formatNumber(1, 5)).toBe("1.00"); 29 | }); 30 | it("should multiple replace extra 0", () => { 31 | expect(formatNumber(1.12345, 6)).toBe("1.12345"); 32 | }); 33 | }); 34 | 35 | describe("floorNumber", () => { 36 | it("should floor a number with 2 decimal places", () => { 37 | expect(floorNumber(1.234)).toBe(1.23); 38 | }); 39 | it("should work on number with 3 decimal places", () => { 40 | expect(floorNumber(1.2342, 3)).toBe(1.234); 41 | }); 42 | it("should floor a number with 3 decimal places", () => { 43 | expect(floorNumber(1.2347, 3)).toBe(1.234); 44 | }); 45 | it("should floor a number with no decimals", () => { 46 | expect(floorNumber(1, 2)).toBe(1); 47 | }); 48 | }); 49 | 50 | describe("ceilNumber", () => { 51 | it("should ceil a number with 2 decimal places", () => { 52 | expect(ceilNumber(1.234)).toBe(1.24); 53 | }); 54 | it("should work on number with 3 decimal places", () => { 55 | expect(ceilNumber(1.2342, 3)).toBe(1.235); 56 | }); 57 | it("should ceil a number with 3 decimal places", () => { 58 | expect(ceilNumber(1.2347, 3)).toBe(1.235); 59 | }); 60 | it("should ceil a number with no decimals", () => { 61 | expect(ceilNumber(1, 2)).toBe(1); 62 | }); 63 | }); 64 | 65 | describe("roundNumber", () => { 66 | it("should round a number with 2 decimal places", () => { 67 | expect(roundNumber(1.234)).toBe(1.23); 68 | }); 69 | it("should work on number with 3 decimal places", () => { 70 | expect(roundNumber(1.2342, 3)).toBe(1.234); 71 | }); 72 | it("should round a number with 3 decimal places", () => { 73 | expect(roundNumber(1.2347, 3)).toBe(1.235); 74 | }); 75 | it("should round a number with no decimals", () => { 76 | expect(roundNumber(1, 2)).toBe(1); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /lib/taxes/taxable-event-fr.ts: -------------------------------------------------------------------------------- 1 | export interface TaxableEventFr { 2 | /** Symbol of the stock */ 3 | symbol: string; 4 | planType: "ESPP" | "RS" | "SO"; 5 | /** What kind of qualified plan is it? */ 6 | qualifiedIn: "fr" | "us"; 7 | /** Taxable event type */ 8 | type: "vesting" | "sell" | "exercise"; 9 | /** 10 | * Date of the taxable event. 11 | * In format YYYY-MM-DD 12 | */ 13 | date: string; 14 | /** Date those stocks were granted */ 15 | dateGranted: string; 16 | /** Number of shares */ 17 | quantity: number; 18 | /** 19 | * Sell information (per share). 20 | * If the taxable event is about a non qualified vesting, this field is null. 21 | */ 22 | sell: { usd: number; rate: number; eur: number; date: string } | null; 23 | /** Acquisition information (per share) */ 24 | acquisition: { 25 | /** Value of the share at acquistion in USD */ 26 | valueUsd: number; 27 | /** Value of the share at acquistion in EUR */ 28 | valueEur: number; 29 | /** Acquisition cost in USD */ 30 | costUsd: number; 31 | /** Acquisition cost in EUR */ 32 | costEur: number; 33 | /** Symbol price at opening price on time of exercise. */ 34 | symbolPrice: number; 35 | /** Symbol price at opening price on time of exercise (in EUR). */ 36 | symbolPriceEur: number; 37 | /** 38 | * Last cotation of the stock at the time of acquisition. 39 | * Sometimes grant date is on a weekend or a holiday. 40 | * If this is filled then the symbolPrice was not available on grant date 41 | * and the last known cotation is used. 42 | */ 43 | dateSymbolPriceAcquired?: string; 44 | /** USD rate at time of acquisition */ 45 | rate: number; 46 | /** 47 | * Acquisition date 48 | * In format YYYY-MM-DD 49 | */ 50 | date: string; 51 | /** 52 | * Describe how the acquisition cost was calculated. 53 | * Examples: 54 | * - DDOG price at opening price on time of exercise. 55 | * - DDOG price at opening price on time of vesting. 56 | * - Sell price as the plan is qualified and the sale is at loss. 57 | * - Sell price as this is a sell to cover. 58 | * - DDOG price at opening price on time of vesting since the plan is not qualified. 59 | * - DDOG price at opening price on time of exercise since the plan is not qualified. 60 | */ 61 | description: string; 62 | }; 63 | /** Computed capital gain for this taxable event */ 64 | capitalGain: { perShare: number; total: number }; 65 | /** Computed acquisition gain for this taxable event */ 66 | acquisitionGain: { 67 | perShare: number; 68 | total: number; 69 | fractionFr: number; 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /lib/date.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatDateFr, 3 | getDateString, 4 | parseEtradeDate, 5 | isWeekend, 6 | dayBefore, 7 | } from "./date"; 8 | 9 | describe("date", () => { 10 | describe("getDateString", () => { 11 | it("should return date in string format", () => { 12 | const date = new Date(Date.UTC(2021, 8 /* month starts at 0... */, 1)); 13 | expect(getDateString(date)).toEqual("2021-09-01"); 14 | }); 15 | }); 16 | 17 | describe("parseEtradeDate", () => { 18 | it("should return date in string format", () => { 19 | const rawDate = "09/01/2021"; 20 | expect(parseEtradeDate(rawDate)).toEqual( 21 | new Date(Date.UTC(2021, 8 /* month starts at 0... */, 1)), 22 | ); 23 | }); 24 | 25 | it("should return a date that can be parsed back", () => { 26 | const rawDate = "09/01/2021"; 27 | const parsed = parseEtradeDate(rawDate); 28 | 29 | expect(new Date(parsed).toISOString()).toEqual( 30 | "2021-09-01T00:00:00.000Z", 31 | ); 32 | }); 33 | }); 34 | 35 | describe("formatDateFr", () => { 36 | it("should return a French date", () => { 37 | expect(formatDateFr("2021-09-01")).toEqual("1 sep 2021"); 38 | }); 39 | 40 | it("should throw if date format is not valid", () => { 41 | expect(() => formatDateFr("2021-09-01-01")).toThrow( 42 | "Invalid date format", 43 | ); 44 | }); 45 | 46 | it("should throw if month is invalid", () => { 47 | expect(() => formatDateFr("2021-13-01")).toThrow("Invalid month"); 48 | }); 49 | 50 | it("should throw if day is invalid", () => { 51 | expect(() => formatDateFr("2021-09-32")).toThrow("Invalid day"); 52 | }); 53 | }); 54 | 55 | describe("isWeekend", () => { 56 | it("should return true if date is a weekend", () => { 57 | expect(isWeekend("2021-09-04")).toEqual(true); 58 | expect(isWeekend("2021-09-05")).toEqual(true); 59 | }); 60 | 61 | it("should return false if date is not a weekend", () => { 62 | expect(isWeekend("2021-09-06")).toEqual(false); 63 | }); 64 | 65 | it("should throw if date format is not valid", () => { 66 | expect(() => isWeekend("2021-09-01-01")).toThrow("Invalid date format"); 67 | }); 68 | }); 69 | 70 | describe("dayBefore", () => { 71 | it("should return the day before", () => { 72 | expect(dayBefore("2021-09-02")).toEqual("2021-09-01"); 73 | }); 74 | it("should return the day before, new month", () => { 75 | expect(dayBefore("2021-09-01")).toEqual("2021-08-31"); 76 | }); 77 | 78 | it("should throw if date format is not valid", () => { 79 | expect(() => dayBefore("2021-09-01-01")).toThrow("Invalid date format"); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /lib/etrade/etrade.types.ts: -------------------------------------------------------------------------------- 1 | export type PlanType = "ESPP" | "RS" | "SO"; 2 | export type PlanQualification = "Qualified" | "Non-Qualified"; 3 | 4 | /** 5 | * Original format for the XLSX file rows in the Etrade Gain/Loss report. 6 | */ 7 | export interface GainAndLossEventXlsxRowPrior2025 { 8 | "Plan Type": PlanType; 9 | Symbol: string; 10 | "Qty.": number; 11 | "Date Acquired": string; 12 | "Date Sold": string; 13 | "Adjusted Cost Basis Per Share": number; 14 | "Acquisition Cost Per Share": number; 15 | "Purchase Date Fair Mkt. Value": string | number; 16 | "Proceeds Per Share": number; 17 | "Qualified Plan": PlanQualification; 18 | "Grant Date": string; 19 | } 20 | export interface GainAndLossEventXlsxRow2025 { 21 | "Plan Type": PlanType; 22 | Symbol: string; 23 | Quantity: number; 24 | "Date Acquired": string; 25 | "Date Sold": string; 26 | "Adjusted Cost Basis Per Share": number; 27 | "Acquisition Cost Per Share": number; 28 | "Purchase Date Fair Mkt. Value": string | number; 29 | "Proceeds Per Share": number; 30 | "Qualified Plan": PlanQualification; 31 | "Grant Date": string; 32 | } 33 | 34 | export type GainAndLossEventXlsxRow = 35 | | GainAndLossEventXlsxRowPrior2025 36 | | GainAndLossEventXlsxRow2025; 37 | 38 | /** 39 | * Data format for a single sale event after parsing the Etrade Gain/Loss report. 40 | */ 41 | export interface GainAndLossEvent { 42 | planType: PlanType; 43 | symbol: string; 44 | quantity: number; 45 | dateGranted: string; 46 | dateAcquired: string; 47 | dateSold: string; 48 | /** Proceeds per share in USD: sell price. */ 49 | proceeds: number; 50 | /** 51 | * Value when the share was acquired for US taxes. 52 | * Usually SYMBOL price at closing on time of acquisition. 53 | * 54 | * For French taxes, it should be the opening price of the day. 55 | * See https://bofip.impots.gouv.fr/bofip/5654-PGP.html/identifiant%3DBOI-RSA-ES-20-20-20-20170724#:~:text=120,au%20m%C3%AAme%20jour. 56 | */ 57 | adjustedCost: number; 58 | /** 59 | * Acquisition cost per share in USD. 60 | * The price, in USD at which the share was acquired. 61 | * 62 | * This is 0 for RSUs, adjustedCost for ESPP and the grant price for SO. 63 | */ 64 | acquisitionCost: number; 65 | /** 66 | * Fair market value of the share at the time of purchase. 67 | * This is used for ESPP or SO acquired before IPO. 68 | * ⚠️ This can be empty or 0, in this case, the `adjustedCost` is used. 69 | */ 70 | purchaseDateFairMktValue: number; 71 | /** 72 | dateGranted: string; 73 | dateAcquired: string; 74 | dateSold: string; 75 | /** What kind of qualified plan is it? */ 76 | qualifiedIn: "fr" | "us"; 77 | } 78 | 79 | export interface BenefitHistoryEvent { 80 | planType: PlanType; 81 | dateVested: string; 82 | quantity: number; 83 | /** What kind of qualified plan is it? */ 84 | qualified: "FR" | "US"; 85 | } 86 | -------------------------------------------------------------------------------- /app/api/euro/[date]/route.ts: -------------------------------------------------------------------------------- 1 | const API_BASE_URL = 2 | "https://data-api.ecb.europa.eu/service/data/EXR/D.USD.EUR.SP00.A"; 3 | 4 | interface Series { 5 | "0:0:0:0:0": { 6 | observations: { 7 | "0": [number]; 8 | }; 9 | }; 10 | } 11 | interface DataSet { 12 | action: string; 13 | validFrom: string; 14 | series: Series; 15 | } 16 | interface Response { 17 | dataSets: DataSet[]; 18 | } 19 | 20 | const baseFetchExchangeRate = async (date: string): Promise => { 21 | const url = new URL(API_BASE_URL); 22 | url.searchParams.set("format", "jsondata"); 23 | url.searchParams.set("detail", "dataonly"); 24 | url.searchParams.set("startPeriod", date); 25 | url.searchParams.set("endPeriod", date); 26 | 27 | // Fetch the exchange rate, with no cache to avoid having staled data 28 | return fetch(url, { cache: "no-cache" }) 29 | .then((res) => { 30 | try { 31 | return res.json(); 32 | } catch (error) { 33 | // Transform the error into a more user-friendly one 34 | throw new Error( 35 | `${(error as Error).message} for ${date} 36 | Response status: ${res.statusText} ${res.statusText} 37 | Response body: 38 | ${res.text()}`, 39 | ); 40 | } 41 | }) 42 | .then( 43 | (data: Response) => 44 | data.dataSets[0].series["0:0:0:0:0"].observations[0][0], 45 | ); 46 | }; 47 | 48 | /** 49 | * Use a Map of promises instead of map of numbers, so that if we receive 2 requests at the same time on the same date, 50 | * we can start only 1, and use the same promise for both. 51 | */ 52 | const CACHE_EXCHANGE_RATE_PROMISES = new Map>(); 53 | const fetchExchangeRate = async (date: string): Promise => { 54 | const cachedPromiseExchangeRate = CACHE_EXCHANGE_RATE_PROMISES.get(date); 55 | if (cachedPromiseExchangeRate != null) { 56 | return cachedPromiseExchangeRate; 57 | } 58 | 59 | const exchangeRatePromise = baseFetchExchangeRate(date); 60 | // Eagerly set in the cache 61 | CACHE_EXCHANGE_RATE_PROMISES.set(date, exchangeRatePromise); 62 | 63 | // Auto clean if the promise throws (only keep clean ones in the cache) 64 | exchangeRatePromise.catch(() => { 65 | CACHE_EXCHANGE_RATE_PROMISES.delete(date); 66 | }); 67 | 68 | return exchangeRatePromise; 69 | }; 70 | 71 | export async function GET( 72 | _request: Request, 73 | { params }: { params: { date: string } }, 74 | ) { 75 | try { 76 | const exchangeRate = await fetchExchangeRate(params.date); 77 | return Response.json(exchangeRate, { status: 200 }); 78 | } catch (error) { 79 | console.error(error); 80 | // Invalidate the cache, just in case 81 | if (CACHE_EXCHANGE_RATE_PROMISES.has(params.date)) { 82 | CACHE_EXCHANGE_RATE_PROMISES.delete(params.date); 83 | } 84 | return Response.json( 85 | { 86 | error: 87 | (error as Error).message || `Failed to fetch euro for ${params.date}`, 88 | }, 89 | { status: 500 }, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/api/stock/[symbol]/daily/route.ts: -------------------------------------------------------------------------------- 1 | import { ONE_DAY } from "@/lib/constants"; 2 | import type { ApiDate, SymbolDailyResponse } from "@/lib/symbol-daily.types"; 3 | 4 | export interface SymbolDailyAlphavantageResponse { 5 | "Time Series (Daily)": 6 | | { 7 | [date: ApiDate]: { 8 | "1. open": string; 9 | "2. high": string; 10 | "3. low": string; 11 | "4. close": string; 12 | "5. volume": string; 13 | }; 14 | } 15 | | undefined; 16 | } 17 | 18 | const cachedData: { 19 | [symbol: string]: { values: SymbolDailyResponse; cachedTime: number } | null; 20 | } = {}; 21 | 22 | const apiUrl = "https://www.alphavantage.co/query"; 23 | let apiKey = ""; 24 | 25 | const loadApiKey = () => { 26 | if (!process.env.ALPHA_VANTAGE_API_KEY) { 27 | throw new Error(` 28 | [env:${process.env.NODE_ENV}] 29 | ALPHA_VANTAGE_API_KEY is not set. 30 | Please set it in your .env.local file. 31 | 32 | To create a new API key, visit: https://www.alphavantage.co/support/#api-key 33 | `); 34 | } 35 | apiKey = process.env.ALPHA_VANTAGE_API_KEY; 36 | }; 37 | 38 | export async function GET( 39 | _request: Request, 40 | { params }: { params: { symbol: string } }, 41 | ) { 42 | if (!apiKey) { 43 | loadApiKey(); 44 | } 45 | 46 | const symbol = params.symbol; 47 | try { 48 | const cache = cachedData[symbol]; 49 | if (!cache || Date.now() - cache.cachedTime > ONE_DAY) { 50 | const searchParams = new URLSearchParams({ 51 | function: "TIME_SERIES_DAILY", 52 | symbol, 53 | outputsize: "full", 54 | apikey: apiKey, 55 | }); 56 | cachedData[symbol] = await fetch(`${apiUrl}?${searchParams.toString()}`) 57 | .then((res) => res.json()) 58 | .then((response: SymbolDailyAlphavantageResponse) => { 59 | if ("Error Message" in response) { 60 | throw new Error( 61 | `Failed to fetch symbol ${symbol} prices with: "${response["Error Message"]}"`, 62 | ); 63 | } 64 | const data: SymbolDailyResponse = {}; 65 | const dailyTs = response["Time Series (Daily)"] 66 | ? Object.entries(response["Time Series (Daily)"]) 67 | : []; 68 | for (const [date, values] of dailyTs) { 69 | data[date] = { 70 | opening: parseFloat(values["1. open"]), 71 | closing: parseFloat(values["4. close"]), 72 | }; 73 | } 74 | 75 | return { values: data, cachedTime: Date.now() }; 76 | }); 77 | } 78 | 79 | if (!cachedData[symbol]) { 80 | throw new Error(`Failed to fetch symbol ${symbol} prices`); 81 | } 82 | return Response.json(cachedData[symbol]?.values, { status: 200 }); 83 | } catch (error) { 84 | console.error(error); 85 | return Response.json( 86 | { 87 | error: (error as Error).message || `Failed to fetch ${symbol} prices`, 88 | }, 89 | { status: 500 }, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/report/_TaxReportBox.tsx: -------------------------------------------------------------------------------- 1 | import { Drawer } from "@/components/ui/Drawer"; 2 | import { TaxableEventFr } from "@/components/TaxableEventFr"; 3 | import type { TaxableEventFr as TaxableEventFrProps } from "@/lib/taxes/taxable-event-fr"; 4 | import { Tooltip } from "@/components/ui/Tooltip"; 5 | import { InformationCircleIcon } from "@heroicons/react/24/solid"; 6 | 7 | export const TaxReportBox: React.FunctionComponent<{ 8 | /** Unique id for the box (1TT, 3VG...) */ 9 | id: string; 10 | /** Human readable title of the box */ 11 | title: React.ReactNode; 12 | /** Value to fill the box with */ 13 | amount: number | string; 14 | /** Detailed explanation how the amount was computed */ 15 | explanations: { 16 | box: string; 17 | description: string; 18 | taxableEvents: TaxableEventFrProps[]; 19 | }[]; 20 | gainType: "acquisition" | "capital"; 21 | forceOpen?: boolean; 22 | }> = ({ id, title, amount, explanations, gainType, forceOpen }) => { 23 | const relatedExplanations = explanations.filter(({ box }) => box === id); 24 | return ( 25 |
26 |
27 |

{id}

28 | 29 | {typeof amount === "number" 30 | ? `${Math.floor(amount) /* Tax form only accepts integers */} €` 31 | : amount} 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | {relatedExplanations.length > 0 && ( 40 | 41 |
42 | {relatedExplanations.map((explanation) => ( 43 |
44 | {explanation.taxableEvents.length > 0 ? ( 45 | {explanation.description}} 47 | forceOpen={forceOpen} 48 | > 49 | {explanation.taxableEvents.map((taxableEvent, index) => ( 50 |
54 | 60 |
61 | ))} 62 |
63 | ) : ( 64 | {explanation.description} 65 | )} 66 |
67 | ))} 68 |
69 |
70 | )} 71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /app/report/_ReportUs.tsx: -------------------------------------------------------------------------------- 1 | import { Section } from "@/components/ui/Section"; 2 | import type { FrTaxes } from "@/lib/taxes/taxes-rules-fr"; 3 | import Image from "next/image"; 4 | import { TaxReportBox } from "./_TaxReportBox"; 5 | import { match } from "ts-pattern"; 6 | 7 | interface ReportUsProps { 8 | isPrintMode: boolean; 9 | taxes: FrTaxes; 10 | } 11 | 12 | export const ReportUs = ({ isPrintMode, taxes }: ReportUsProps) => { 13 | return ( 14 | <> 15 |
16 |
17 |
18 | {match({ 19 | hasAcquisitionGains: taxes["1TT"] !== 0 || taxes["1TZ"] !== 0, 20 | }) 21 | .with( 22 | { 23 | hasAcquisitionGains: true, 24 | }, 25 | () => ( 26 | select 'Salaires, gains d'actionnariat salarié' 32 | ), 33 | ) 34 | .with( 35 | { 36 | hasAcquisitionGains: false, 37 | }, 38 | () => ( 39 | No specific income selection 45 | ), 46 | ) 47 | .exhaustive()} 48 |
49 |
50 |
51 |
52 |
53 | 61 | 69 | 77 | 85 |
86 |
87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /components/ui/Field.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import type { InputHTMLAttributes, ReactNode } from "react"; 3 | import { LoadingIndicator } from "./LoadingIndicator"; 4 | import Tippy from "@tippyjs/react"; 5 | import "tippy.js/dist/tippy.css"; 6 | import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; 7 | 8 | interface LabelProps { 9 | children: ReactNode; 10 | } 11 | 12 | const Label = ({ children }: LabelProps) => ( 13 | 14 | ); 15 | 16 | interface InputProps 17 | extends Omit, "className" | "value"> { 18 | isLoading?: boolean; 19 | validationError?: string | null; 20 | value: string | number | null | undefined; 21 | } 22 | 23 | const Input = ({ 24 | isLoading, 25 | validationError, 26 | value, 27 | ...htmlInputProps 28 | }: InputProps) => { 29 | return ( 30 | 34 | 35 | {validationError} 36 | 37 | ) : null 38 | } 39 | disabled={!validationError} 40 | > 41 |
42 | {isLoading ? ( 43 |
49 | 50 |
51 | ) : null} 52 | 64 |
65 |
66 | ); 67 | }; 68 | 69 | interface BaseInputProps { 70 | isLoading?: boolean; 71 | isRequired?: boolean; 72 | isReadOnly?: boolean; 73 | value: T | null; 74 | onChange?: (value: T) => void; 75 | min?: T; 76 | max?: T; 77 | placeholder?: string; 78 | validationError?: string | null; 79 | } 80 | 81 | interface NumberInputProps extends BaseInputProps { 82 | maxDecimals?: 0 | 1 | 2; 83 | } 84 | 85 | export const NumberInput = ({ 86 | isLoading, 87 | isReadOnly, 88 | isRequired, 89 | value, 90 | onChange, 91 | min, 92 | max, 93 | placeholder, 94 | maxDecimals = 2, 95 | validationError, 96 | }: NumberInputProps) => ( 97 | onChange?.(event.target.valueAsNumber)} 104 | min={min} 105 | max={max} 106 | step={1 / 10 ** maxDecimals} 107 | validationError={validationError} 108 | isLoading={isLoading} 109 | /> 110 | ); 111 | 112 | interface DateInputProps extends BaseInputProps {} 113 | 114 | export const DateInput = ({ 115 | isLoading, 116 | isReadOnly, 117 | isRequired, 118 | value, 119 | onChange, 120 | min, 121 | max, 122 | placeholder, 123 | validationError, 124 | }: DateInputProps) => ( 125 | onChange?.(event.target.value)} 132 | min={min} 133 | max={max} 134 | validationError={validationError} 135 | isLoading={isLoading} 136 | /> 137 | ); 138 | 139 | type NumberFieldProps = NumberInputProps & { label: string }; 140 | 141 | export const NumberField = ({ label, ...inputProps }: NumberFieldProps) => ( 142 |
143 | 144 | 145 |
146 | ); 147 | 148 | type DateFieldProps = DateInputProps & { label: string }; 149 | 150 | export const DateField = ({ label, ...inputProps }: DateFieldProps) => ( 151 |
152 | 153 | 154 |
155 | ); 156 | -------------------------------------------------------------------------------- /lib/etrade/parse-etrade-gl.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GainAndLossEvent, 3 | GainAndLossEventXlsxRow, 4 | PlanType, 5 | } from "./etrade.types"; 6 | import { getDateString, parseEtradeDate } from "@/lib/date"; 7 | import XLSX from "xlsx"; 8 | 9 | const toDateString = (rawDate: string) => 10 | getDateString(parseEtradeDate(rawDate)); 11 | 12 | const toNumber = (rawNumber: string | number): number => { 13 | const result = typeof rawNumber === "string" ? Number(rawNumber) : rawNumber; 14 | if (isNaN(result)) { 15 | throw new Error(`invalid number: ${rawNumber}`); 16 | } 17 | 18 | return result; 19 | }; 20 | 21 | const ensureDefined = (value: T | undefined, key: string): T => { 22 | if (value === undefined || value === null || value === "") { 23 | throw new Error(`undefined value for ${key}`); 24 | } 25 | return value; 26 | }; 27 | 28 | const parseEtradeGLRow = (row: GainAndLossEventXlsxRow): GainAndLossEvent => { 29 | const planType = row["Plan Type"]; 30 | const symbol = row["Symbol"]; 31 | const quantity = toNumber("Qty." in row ? row["Qty."] : row["Quantity"]); 32 | const dateGranted = toDateString(row["Grant Date"]); 33 | const dateAcquired = toDateString(row["Date Acquired"]); 34 | const dateSold = toDateString(row["Date Sold"]); 35 | const proceeds = toNumber(row["Proceeds Per Share"]); 36 | // FIXME: Adjusted cost from ETrade's G&L is the close price on day acquired, 37 | // France expects the opening price on day acquired. 38 | // See https://bofip.impots.gouv.fr/bofip/5654-PGP.html/identifiant%3DBOI-RSA-ES-20-20-20-20170724#:~:text=a.%20Actions%20cot%C3%A9es-,120,-La%20valeur%20%C3%A0 39 | const adjustedCost = toNumber(row["Adjusted Cost Basis Per Share"]); 40 | const acquisitionCost = toNumber(row["Acquisition Cost Per Share"]); 41 | // It's unclear why this is a string and not a number. 42 | const purchaseDateFairMktValue = toNumber( 43 | row["Purchase Date Fair Mkt. Value"], 44 | ); 45 | // For now consider that a non-US qualified plan is FR qualified. 46 | // FIXME: this is actually wrong, ETrade doesn't fill in the "Qualified Plan" column for qualified RSU plans. 47 | const qualifiedIn = row["Qualified Plan"] === "Qualified" ? "us" : "fr"; 48 | 49 | // Make sure each row is valid 50 | return { 51 | planType: ensureDefined(planType, "planType"), 52 | symbol: ensureDefined(symbol, "symbol"), 53 | quantity: ensureDefined(quantity, "quantity"), 54 | dateGranted: ensureDefined(dateGranted, "dateGranted"), 55 | dateAcquired: ensureDefined(dateAcquired, "dateAcquired"), 56 | dateSold: ensureDefined(dateSold, "dateSold"), 57 | proceeds: ensureDefined(proceeds, "proceeds"), 58 | adjustedCost: ensureDefined(adjustedCost, "adjustedCost"), 59 | acquisitionCost: ensureDefined(acquisitionCost, "acquisitionCost"), 60 | purchaseDateFairMktValue: ensureDefined( 61 | purchaseDateFairMktValue, 62 | "purchaseDateFairMktValue", 63 | ), 64 | qualifiedIn: ensureDefined(qualifiedIn, "qualifiedIn"), 65 | }; 66 | }; 67 | 68 | export const parseEtradeGL = async ( 69 | file: File, 70 | ): Promise => { 71 | const data: GainAndLossEvent[] = []; 72 | const fileAsArrayBuffer = await file.arrayBuffer(); 73 | const workbook = XLSX.read(fileAsArrayBuffer); 74 | const worksheet = workbook.Sheets[workbook.SheetNames[0]]; 75 | const rawData = XLSX.utils.sheet_to_json(worksheet, { header: 2 }); 76 | // First row is summary 77 | for (let rowIdx = 1; rowIdx < rawData.length; rowIdx++) { 78 | const row = rawData[rowIdx] as GainAndLossEventXlsxRow; 79 | try { 80 | data.push(parseEtradeGLRow(row)); 81 | } catch (e) { 82 | return Promise.reject( 83 | `format of file '${file.name}' is not supported: ${e}`, 84 | ); 85 | } 86 | } 87 | 88 | return Promise.resolve(data); 89 | }; 90 | 91 | /** 92 | * Create a filter function from provided filters. 93 | * 94 | * To get every FR qualified stock options events from the Etrade Gain/Loss 95 | * report: 96 | * 97 | * ```ts 98 | * const isFrQualifiedStock = createEtradeGLFilter({ 99 | * planType: "SO", 100 | * qualifiedIn: "fr", 101 | * }); 102 | * 103 | * const frQualifiedStockEvents = gainAndLossEvents.filter(isFrQualifiedStock); 104 | * ``` 105 | */ 106 | export const createEtradeGLFilter = (filter: { 107 | planType?: PlanType; 108 | qualifiedIn?: "fr" | "us"; 109 | }) => { 110 | const filterKeys = Object.keys(filter) as (keyof typeof filter)[]; 111 | return function filterEtradeGLFilter(event: GainAndLossEvent): boolean { 112 | return filterKeys.every((key) => { 113 | if (filter[key] === undefined) { 114 | return true; 115 | } 116 | return event[key] === filter[key]; 117 | }); 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /app/report/_FractionAssignmentModal.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useState } from "react"; 2 | import { Button } from "@/components/ui/Button"; 3 | import { CheckIcon, XMarkIcon } from "@heroicons/react/24/solid"; 4 | import { NumberInput } from "@/components/ui/Field"; 5 | import type { GainAndLossEvent } from "@/lib/etrade/etrade.types"; 6 | import { Modal } from "@/components/ui/Modal"; 7 | import { match } from "ts-pattern"; 8 | import { LoadingIndicator } from "@/components/ui/LoadingIndicator"; 9 | import { MessageBox } from "@/components/ui/MessageBox"; 10 | 11 | interface FractionAssignmentModalProps { 12 | data: GainAndLossEvent[]; 13 | showModal: boolean; 14 | setShowModal: (show: boolean) => void; 15 | confirm: (fractions: number[]) => void; 16 | state: "loading" | "error" | "ok"; 17 | } 18 | 19 | const toKey = (e: GainAndLossEvent) => `${e.dateGranted},${e.dateAcquired}`; 20 | const fromKey = (pair: string) => pair.split(","); 21 | 22 | const sortByDates = (pairA: string, pairB: string) => { 23 | const [aGranted, aAcquired] = fromKey(pairA); 24 | const [bGranted, bAcquired] = fromKey(pairB); 25 | return aAcquired.localeCompare(bAcquired) || aGranted.localeCompare(bGranted); 26 | }; 27 | 28 | const fractionsFromEvents = ( 29 | events: GainAndLossEvent[], 30 | pctMap: Map, 31 | ) => 32 | events.map((e) => { 33 | const datePair = toKey(e); 34 | return (pctMap.get(datePair) ?? 100) / 100; // normalize before sending back 35 | }); 36 | 37 | export const FractionAssignmentModal = ({ 38 | data, 39 | showModal, 40 | setShowModal, 41 | confirm, 42 | state, 43 | }: FractionAssignmentModalProps) => { 44 | // % are the same for each date acquired / date granted pair. 45 | const [pctMap, setPctMap] = useState>( 46 | new Map(), 47 | ); 48 | 49 | // Reset % if data changes 50 | useEffect(() => { 51 | setPctMap(new Map()); 52 | }, [data]); 53 | 54 | const salesByDates = Map.groupBy( 55 | data 56 | .map((e, eventIdx) => ({ ...e, index: eventIdx })) 57 | .filter((e) => e.planType === "RS"), // origin of income only applies to RSUs 58 | toKey, 59 | ); 60 | 61 | return ( 62 | 63 |
64 |
65 |
66 | Confirm the origin of your income 67 |
68 |
74 |
75 | For each sale, please confirm the % of French income. If you have 76 | never moved abroad, it should be 100%. 77 |
78 | 79 | If you would like support for other types of equity (e.g. stock 80 | options), please reach out with examples. 81 | 82 | {match(state) 83 | .with("ok", () => ( 84 | <> 85 |
86 | {["Grant Date", "Acquisition Date", "% FR"].map((h) => ( 87 |
88 | {h} 89 |
90 | ))} 91 | {Array.from(salesByDates.keys()) 92 | .sort(sortByDates) 93 | .map((datePair) => { 94 | const [granted, acquired] = fromKey(datePair); 95 | return ( 96 | 97 |
{granted}
98 |
{acquired}
99 | { 105 | setPctMap(new Map(pctMap.set(datePair, value))); 106 | }} 107 | /> 108 |
109 | ); 110 | })} 111 |
112 |
113 |
123 | 124 | )) 125 | .with("loading", () => ( 126 |
127 | 128 |
129 | )) 130 | .with("error", () => ( 131 | 135 | )) 136 | .exhaustive()} 137 |
138 |
139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /components/TaxableEventFr.tsx: -------------------------------------------------------------------------------- 1 | import type { TaxableEventFr as TaxableEventFrProps } from "@/lib/taxes/taxable-event-fr"; 2 | import { match } from "ts-pattern"; 3 | import { Drawer } from "./ui/Drawer"; 4 | import { Currency } from "@/components/ui/Currency"; 5 | import { PriceInEuro } from "./ui/PriceInEuro"; 6 | import { formatDateFr } from "@/lib/date"; 7 | import { Tooltip } from "./ui/Tooltip"; 8 | import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; 9 | import { formatNumber } from "@/lib/format-number"; 10 | 11 | export const TaxableEventFr: React.FunctionComponent<{ 12 | event: TaxableEventFrProps; 13 | showAcquisitionGains?: boolean; 14 | showCapitalGains?: boolean; 15 | forceOpen?: boolean; 16 | }> = ({ 17 | event, 18 | showCapitalGains = false, 19 | showAcquisitionGains = false, 20 | forceOpen, 21 | }) => { 22 | const asset = match(event.planType) 23 | .with("ESPP", () => "ESPP") 24 | .with("RS", () => "RSU") 25 | .with("SO", () => "Stock options") 26 | .exhaustive(); 27 | 28 | const trigger = match(event.type) 29 | .with("vesting", () => "vested") 30 | .with("sell", () => "sold") 31 | .with("exercise", () => "exercised") 32 | .exhaustive(); 33 | 34 | return ( 35 | 39 |

40 | On {formatDateFr(event.date)} {trigger} {event.quantity} {asset} 41 |

42 | 43 |
44 | {showCapitalGains && ( 45 | <> 46 |
Capital gain
47 |
48 | 49 |
50 | 51 | )} 52 | {showAcquisitionGains && ( 53 | <> 54 |
Acquisition gain
55 |
56 | 57 |
58 | 59 | )} 60 |
61 | 62 | } 63 | > 64 | 65 |
66 |

67 | Granted: {formatDateFr(event.dateGranted)}. 68 |

69 |

70 | Acquired: {formatDateFr(event.acquisition.date)}. 71 |

72 | {event.sell && ( 73 |

74 | Sold: {formatDateFr(event.sell.date)}. 75 |

76 | )} 77 |
78 |
79 | 80 | {" "} 87 | per share. 88 | 89 | 90 | {" "} 97 | per share ({event.acquisition.description}) 98 | 99 | {event.acquisitionGain.fractionFr < 1 ? ( 100 | 101 | 102 | {formatNumber(event.acquisitionGain.fractionFr * 100)}% 103 | 104 | 105 | ) : null} 106 | {event.sell && ( 107 | 108 | {" "} 115 | per share. 116 | 117 | )} 118 | 119 |

120 | {" "} 130 | at opening on acquisition day. 131 |

132 | {event.acquisition.dateSymbolPriceAcquired && ( 133 |

134 | 135 | {event.symbol} price was not available on{" "} 136 | {formatDateFr(event.acquisition.date)}, last known price (on{" "} 137 | {formatDateFr(event.acquisition.dateSymbolPriceAcquired)}) was used 138 | instead. 139 |

140 | )} 141 |
142 | {(showAcquisitionGains || showCapitalGains) && ( 143 |
144 | )} 145 | {showAcquisitionGains && ( 146 | 150 | {" "} 155 | per share. 156 | 157 | )} 158 | {showCapitalGains && ( 159 | 163 | {" "} 168 | per share. 169 | 170 | )} 171 |
172 | ); 173 | }; 174 | interface TaxableEventFrLineProps { 175 | title: React.ReactNode; 176 | children: React.ReactNode; 177 | tooltip?: React.ReactNode; 178 | } 179 | const TaxableEventFrLine: React.FunctionComponent = ({ 180 | title, 181 | tooltip, 182 | children, 183 | }) => ( 184 |
185 |

186 | {tooltip ? ( 187 | 188 | {title} 189 | 190 | ) : ( 191 | title 192 | )} 193 |

194 |
{children}
195 |
196 | ); 197 | -------------------------------------------------------------------------------- /app/report/_Report.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { match } from "ts-pattern"; 3 | import { EtradeGainAndLossesFileInput } from "@/components/EtradeGainAndLossesFileInput"; 4 | import { useExchangeRates } from "@/hooks/use-fetch-exr"; 5 | import { Button } from "@/components/ui/Button"; 6 | import type { GainAndLossEvent } from "@/lib/etrade/etrade.types"; 7 | import { applyFrTaxes, getEmptyTaxes } from "@/lib/taxes/taxes-rules-fr"; 8 | import { Section } from "@/components/ui/Section"; 9 | import { 10 | isEspp, 11 | isFrQualifiedRsu, 12 | isFrQualifiedSo, 13 | isUsQualifiedRsu, 14 | isUsQualifiedSo, 15 | } from "@/lib/etrade/filters"; 16 | import { Tooltip } from "@/components/ui/Tooltip"; 17 | import { MessageBox } from "@/components/ui/MessageBox"; 18 | import { InformationCircleIcon } from "@heroicons/react/24/solid"; 19 | import { useFetchSymbolDaily } from "@/hooks/use-fetch-symbol-daily"; 20 | import { Link } from "@/components/ui/Link"; 21 | import { FractionAssignmentModal } from "./_FractionAssignmentModal"; 22 | import { sendErrorToast } from "@/components/ui/Toast"; 23 | import { ReportFr } from "./_ReportFr"; 24 | import type { CountryCode } from "./types"; 25 | import { ReportUs } from "./_ReportUs"; 26 | 27 | export interface ReportResidencyFrProps { 28 | taxResidency: CountryCode; 29 | } 30 | 31 | export const Report: React.FunctionComponent = ({ 32 | taxResidency, 33 | }: ReportResidencyFrProps) => { 34 | const [showFractionAssignmentModal, setShowFractionAssignmentModal] = 35 | useState(false); 36 | const [gainsAndLosses, setGainsAndLosses] = useState([]); 37 | const [fractionsFrIncome, setFractionsFrIncome] = useState([]); 38 | const [isPrintMode, setIsPrintMode] = useState(false); 39 | 40 | const { 41 | values: rates, 42 | isFetching: isFetchingExr, 43 | isError: couldNotFetchRates, 44 | } = useExchangeRates( 45 | gainsAndLosses.flatMap((event) => [event.dateSold, event.dateAcquired]), 46 | ); 47 | useEffect(() => { 48 | if (couldNotFetchRates) { 49 | sendErrorToast("could not fetch exchange rates, please retry later"); 50 | } 51 | }, [couldNotFetchRates]); 52 | 53 | const { 54 | values: symbolPrices, 55 | isFetching: isFetchingPrices, 56 | isError: couldNotFetchPrices, 57 | } = useFetchSymbolDaily(gainsAndLosses.map((event) => event.symbol)); 58 | useEffect(() => { 59 | if (couldNotFetchPrices) { 60 | sendErrorToast("could not fetch stock prices, please retry later"); 61 | } 62 | }, [couldNotFetchPrices]); 63 | 64 | const isFetching = isFetchingExr || isFetchingPrices; 65 | const hasError = couldNotFetchRates || couldNotFetchPrices; 66 | 67 | const counts = useMemo( 68 | () => ({ 69 | frQualifiedSo: gainsAndLosses.filter(isFrQualifiedSo).length, 70 | frQualifiedRsu: gainsAndLosses.filter(isFrQualifiedRsu).length, 71 | espp: gainsAndLosses.filter(isEspp).length, 72 | usQualifiedSo: gainsAndLosses.filter(isUsQualifiedSo).length, 73 | usQualifiedRsu: gainsAndLosses.filter(isUsQualifiedRsu).length, 74 | }), 75 | [gainsAndLosses], 76 | ); 77 | 78 | const taxes = useMemo(() => { 79 | if (gainsAndLosses.length === 0 || isFetching || hasError || !rates) { 80 | return getEmptyTaxes(); 81 | } 82 | return applyFrTaxes({ 83 | gainsAndLosses, 84 | benefits: [], 85 | rates, 86 | symbolPrices, 87 | fractions: fractionsFrIncome, 88 | }); 89 | }, [ 90 | gainsAndLosses, 91 | rates, 92 | symbolPrices, 93 | isFetching, 94 | hasError, 95 | fractionsFrIncome, 96 | ]); 97 | 98 | return ( 99 |
100 |
101 | 102 |

103 | These calculations are for informational purposes only and should 104 | not be considered financial advice. 105 |

106 |

107 | Despite all the efforts that were put in creating this tool, it is 108 | your responsibility to verify the results. 109 |

110 |

111 | This{" "} 112 | 113 | guide 114 | {" "} 115 | sent by equity team in 2021 was used to create this calculator 116 |

117 |
118 |
119 | Based on the expanded exports both for Gain And Losses (My 120 | Account > Gains and losses) and Benefit History (My Account > 121 | Benefit History) from Etrade. 122 |
123 |
124 | {gainsAndLosses.length === 0 || 125 | (gainsAndLosses.filter((e) => e.planType === "RS").length > 0 && 126 | fractionsFrIncome.length === 0) ? ( 127 |
128 | Import the Gains and Losses CSV file: 129 | ({ 138 | isFetching, 139 | hasError, 140 | }) 141 | .with({ isFetching: true }, () => "loading") 142 | .with({ hasError: true }, () => "error") 143 | .otherwise(() => "ok")} 144 | /> 145 | { 147 | setGainsAndLosses(data); 148 | if (data.filter((e) => e.planType === "RS").length > 0) { 149 | // Show fraction assignment modal if you sold RSUs 150 | setShowFractionAssignmentModal(true); 151 | } 152 | }} 153 | /> 154 |
155 | ) : isFetching ? ( 156 |

Loading...

157 | ) : ( 158 |
159 |
160 |
161 | Gains and Losses 162 |
171 |
172 | setIsPrintMode(!isPrintMode)} 177 | /> 178 | 179 |
180 |
181 | 182 |
183 |
184 |
185 |
FR qualified SO
186 |
{counts.frQualifiedSo} events
187 |
FR qualified RSU
188 |
{counts.frQualifiedRsu} events
189 |
ESPP
190 |
{counts.espp} events
191 |
US qualified SO
192 |
{counts.usQualifiedSo} events
193 |
US qualified RSU
194 |
{counts.usQualifiedRsu} events
195 |
196 |
197 |
198 | {match({ taxResidency }) 199 | .with({ taxResidency: "fr" }, () => ( 200 | 0} 202 | isPrintMode={isPrintMode} 203 | taxes={taxes} 204 | /> 205 | )) 206 | .with({ taxResidency: "us" }, () => ( 207 | 208 | )) 209 | .exhaustive()} 210 |
211 |
Some external sources are used to compute data:
212 |
    213 |
  • 214 | Etrade Gains and Losses{" "} 215 | 216 | Expanded 217 | 218 |
  • 219 |
  • 220 |
    221 | The exchange rates are fetched from the  222 | 223 | European Central Bank API 224 | 225 | 231 | 232 | 233 |
    234 |
  • 235 |
  • 236 | Stock prices are fetched from  237 | 238 | Alphavantage TIME_SERIES_DAILY 239 | 240 |
  • 241 |
242 |
243 |
244 | )} 245 |
246 | ); 247 | }; 248 | -------------------------------------------------------------------------------- /app/report/_ReportFr.tsx: -------------------------------------------------------------------------------- 1 | import { Section } from "@/components/ui/Section"; 2 | import type { FrTaxes } from "@/lib/taxes/taxes-rules-fr"; 3 | import Image from "next/image"; 4 | import { Link } from "@/components/ui/Link"; 5 | import { TaxReportBox } from "./_TaxReportBox"; 6 | import { Currency } from "@/components/ui/Currency"; 7 | import { Button } from "@/components/ui/Button"; 8 | import { match } from "ts-pattern"; 9 | 10 | import { 11 | ChevronDoubleLeftIcon, 12 | ChevronDoubleRightIcon, 13 | } from "@heroicons/react/24/solid"; 14 | import { useState } from "react"; 15 | import { CopyableCell } from "./_CopyableCell"; 16 | 17 | interface ReportResidencyFrContentProps { 18 | hasSoldShares: boolean; 19 | isPrintMode: boolean; 20 | taxes: FrTaxes; 21 | } 22 | 23 | export const ReportFr = ({ 24 | hasSoldShares, 25 | isPrintMode, 26 | taxes, 27 | }: ReportResidencyFrContentProps) => { 28 | return ( 29 | <> 30 |
31 |
32 |
33 | {match({ 34 | hasCapitalGains: taxes["3VG"] !== 0, 35 | hasAcquisitionGains: taxes["1TT"] !== 0 || taxes["1TZ"] !== 0, 36 | }) 37 | .with( 38 | { 39 | hasCapitalGains: true, 40 | hasAcquisitionGains: true, 41 | }, 42 | () => ( 43 | select 'Salaires, gains d'actionnariat salarié' and 'Plus-values et gains divers' 49 | ), 50 | ) 51 | .with( 52 | { 53 | hasCapitalGains: true, 54 | hasAcquisitionGains: false, 55 | }, 56 | () => ( 57 | select 'Plus-values et gains divers' 63 | ), 64 | ) 65 | .with( 66 | { 67 | hasCapitalGains: false, 68 | hasAcquisitionGains: true, 69 | }, 70 | () => ( 71 | select 'Salaires, gains d'actionnariat salarié' 77 | ), 78 | ) 79 | .with( 80 | { 81 | hasCapitalGains: false, 82 | hasAcquisitionGains: false, 83 | }, 84 | () => ( 85 | No specific income selection 91 | ), 92 | ) 93 | .exhaustive()} 94 | Compte a l'etranger 101 |
102 |
103 | {hasSoldShares ? ( 104 | Select Anexes N° 2074 and N° 3916 - 3916 bis 110 | ) : ( 111 | Select Anexes N° 3916 - 3916 bis 117 | )} 118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |

126 | Make sure you check 8UU 127 |

128 | Check 8UU 134 |
135 |
136 | Find your Morgan Stanley's accounts details in 137 | 138 | profile > account preferences 144 | 145 |
146 |
147 |
148 | Compte a l'etranger 154 |
155 |
156 |
157 |
158 |
159 | 167 | 175 | 183 | 191 | 199 | 207 |
208 |
209 |
210 |
211 |

212 | You must report{" "} 213 | {taxes["Form 2074"]["Page 510"].length} in this 214 | form. 215 |

216 | Form 2074 - Page 1 223 |
224 |
225 | 226 |
227 | {isPrintMode ? null : ( 228 |
229 |
230 | One Last Step For Form 2074 231 |
232 |

233 | You must report{" "} 234 | 235 | 236 | {" "} 237 | on line 1133. 238 |

239 | Form 2074 - Box 1133 246 |
247 | )} 248 |
249 | 250 | ); 251 | }; 252 | 253 | const PAGE_510_LABELS = { 254 | "511": "Désignation des titres et des intermédiaires financiers", 255 | "512": "Date de la cession ou du rachat jj/mm/aaaa", 256 | "513": "Nombre de titres cédés ou rachetés", 257 | "514": "Valeur unitaire de cession", 258 | "515": "Nombre de titres cédés", 259 | "516": "Montant global lignes (514 x 515)", 260 | "517": "Frais de cession cf. notice", 261 | "518": "Prix de cession net lignes (516 - 517)", 262 | "519": "Détermination du prix de revient des titres", 263 | "520": "Prix ou valeur d'acquisition unitaire cf. notice", 264 | "521": "Prix d'acquisition global cf. notice", 265 | "522": "Frais d'acquisition", 266 | "523": "Prix de revient lignes (521 + 522)", 267 | "524": "Résultat précédé du signe + ou - lignes (518 - 523)", 268 | "525": 269 | "Je demande expressément à bénéficier de l'imputation des moins-values préalablement à l'annulation des titres cf. notice", 270 | "526": "Montant des moins-values imputées pour les titres concernés", 271 | }; 272 | 273 | const Page510: React.FunctionComponent<{ 274 | taxes: FrTaxes; 275 | isPrintMode?: boolean; 276 | }> = ({ taxes, isPrintMode }) => { 277 | const [currentIndex, setCurrentIndex] = useState(0); 278 | if (!taxes["Form 2074"]["Page 510"].length) { 279 | return

No taxable events to report.

; 280 | } 281 | const pagesToDisplay = isPrintMode 282 | ? taxes["Form 2074"]["Page 510"] 283 | : [taxes["Form 2074"]["Page 510"][currentIndex]]; 284 | return ( 285 |
286 | {pagesToDisplay.map((currentPage, index) => ( 287 |
288 |

289 | Page {(isPrintMode ? index : currentIndex) + 1} 290 |

291 | 292 | 293 | {Object.keys(currentPage).map((key) => { 294 | const value = currentPage[key as keyof typeof currentPage]; 295 | return ( 296 | 300 | 301 | 302 | 309 | 310 | ); 311 | })} 312 | 313 |
{key}{PAGE_510_LABELS[key as keyof typeof currentPage]} 303 | {typeof value === "boolean" ? ( 304 | 305 | ) : value !== undefined ? ( 306 | 307 | ) : null} 308 |
314 |
315 | ))} 316 | {!isPrintMode && ( 317 |
318 |
335 | )} 336 |
337 | ); 338 | }; 339 | -------------------------------------------------------------------------------- /lib/taxes/taxes-rules-fr.ts: -------------------------------------------------------------------------------- 1 | import type { SymbolDailyResponse } from "@/lib/symbol-daily.types"; 2 | import type { 3 | BenefitHistoryEvent, 4 | GainAndLossEvent, 5 | } from "@/lib/etrade/etrade.types"; 6 | import { 7 | isEspp, 8 | isFrQualifiedRsu, 9 | isFrQualifiedSo, 10 | isUsQualifiedRsu, 11 | isUsQualifiedSo, 12 | } from "@/lib/etrade/filters"; 13 | import { 14 | floorNumber, 15 | ceilNumber, 16 | formatNumber, 17 | roundNumber, 18 | } from "@/lib/format-number"; 19 | import type { TaxableEventFr } from "./taxable-event-fr"; 20 | 21 | export interface GainAndLossEventWithRates extends GainAndLossEvent { 22 | rateAcquired: number; 23 | rateSold: number; 24 | symbolPriceAcquired: number; 25 | /** People that have spent time abroad and have vested stocks in another country 26 | * will only have a fraction of the income to report to the French authorities. 27 | * 0 < `fractionFrIncome` < 1. By default, fractionFrIncome = 1. 28 | */ 29 | fractionFrIncome: number; 30 | /** 31 | * Sometimes grant date is on a weekend. 32 | * This leads to a missing symbol price since markets are closed. 33 | * If the symbol price for dateAcquired is available, this field is undefined. 34 | * Otherwise it is the date of the symbol price used. 35 | */ 36 | dateSymbolPriceAcquired?: string; 37 | } 38 | 39 | export interface BenefitEventWithRates extends BenefitHistoryEvent {} 40 | 41 | /** French taxes uses 6 digit precision for Form 2074 */ 42 | const floorNumber6Digits = (value: number): number => floorNumber(value, 6); 43 | const floorNumber0Digits = (value: number): number => floorNumber(value, 0); 44 | const ceilNumber2Digits = (value: number): number => ceilNumber(value, 2); 45 | const roundNumber0Digits = (value: number): number => roundNumber(value, 0); 46 | 47 | // Qualified: 48 | // SO: 49 | // - Acquisition gain => Income, 1TT: WARNING: use Min(exercise price, sell price) 50 | // - capital gain => 51 | // ESPP: 52 | // - Acquisition gain => 0 53 | // - capital gain => 54 | // RS: 55 | // - Acquisition gain < 300k€ => gains/2 1TZ 56 | // - Acquisition gain >= 300k€ => 1TT 57 | // - capital gain => 58 | // 59 | // Non-Qualified: 60 | // SO: 61 | // - Acquisition gain => Income the year of vesting, split between US and 62 | // France prorata of time spent in each country. 1AJ. This is treated through 63 | // payslip, hence there is nothing needed here. 64 | // - capital gain => 65 | // ESPP: 66 | // - N/A? 67 | // RS: 68 | // - Acquisition gain => Income the year of vesting, split between US and 69 | // France prorata of time spent in each country. 1AJ. This is treated through 70 | // payslip, hence there is nothing needed here. 71 | // - capital gain => 72 | 73 | /** Sometimes grant date is on a weekend, we need to adjust the date */ 74 | const getAdjustedSymbolDate = ( 75 | date: string, 76 | symbolPrices: SymbolDailyResponse, 77 | ): string | undefined => { 78 | // To avoid infinite loop, set a max number of days to consider. 79 | const maxIterations = 10; 80 | let currentIteration = 0; 81 | 82 | let dateToConsider = date; 83 | let symbolDate = symbolPrices[dateToConsider]; 84 | 85 | while (!symbolDate && currentIteration < maxIterations) { 86 | // Try the day before 87 | const [dateYear, dateMonth, dateDay] = dateToConsider.split("-"); 88 | dateToConsider = new Date( 89 | Date.UTC(Number(dateYear), Number(dateMonth) - 1, Number(dateDay) - 1), 90 | ) 91 | .toISOString() 92 | .substring(0, 10); 93 | symbolDate = symbolPrices[dateToConsider]; 94 | currentIteration++; 95 | } 96 | 97 | if (!symbolDate) { 98 | return undefined; 99 | } 100 | return dateToConsider; 101 | }; 102 | 103 | export const enrichEtradeGlFrFr = ( 104 | data: GainAndLossEvent[], 105 | { 106 | fractions, 107 | rates, 108 | symbolPrices, 109 | }: { 110 | rates: { 111 | [date: string]: number; 112 | }; 113 | symbolPrices: { [symbol: string]: SymbolDailyResponse }; 114 | fractions: number[]; 115 | }, 116 | ): GainAndLossEventWithRates[] => { 117 | return data 118 | .sort((a, b) => { 119 | // sort by dateSold 120 | return new Date(a.dateSold).getTime() - new Date(b.dateSold).getTime(); 121 | }) 122 | .map((event, eventIdx) => { 123 | const rateAcquired = rates[event.dateAcquired]; 124 | const rateSold = rates[event.dateSold]; 125 | const dateSymbolPriceAcquired = getAdjustedSymbolDate( 126 | event.dateAcquired, 127 | symbolPrices[event.symbol], 128 | ); 129 | const symbolPriceAcquired = dateSymbolPriceAcquired 130 | ? symbolPrices[event.symbol][dateSymbolPriceAcquired].opening 131 | : event.purchaseDateFairMktValue && 132 | !Number.isNaN(event.purchaseDateFairMktValue) 133 | ? event.purchaseDateFairMktValue // Given the symbol was not publicly traded, use the Fair Market Value 134 | : Number(event.adjustedCost); // Use the adjusted cost basis per share as last resort if purchaseDateFairMktValue is not available 135 | 136 | return { 137 | ...event, 138 | rateAcquired: rateAcquired, 139 | rateSold: rateSold, 140 | symbolPriceAcquired, 141 | dateSymbolPriceAcquired: 142 | dateSymbolPriceAcquired !== event.dateAcquired 143 | ? dateSymbolPriceAcquired 144 | : undefined, 145 | fractionFrIncome: eventIdx in fractions ? fractions[eventIdx] : 1, 146 | }; 147 | }); 148 | }; 149 | 150 | const enrichEtradeBenefitsFrFr = ( 151 | data: BenefitHistoryEvent[], 152 | { 153 | rates, 154 | symbolPrices, 155 | }: { 156 | rates: { 157 | [date: string]: number; 158 | }; 159 | symbolPrices: { [symbol: string]: SymbolDailyResponse }; 160 | }, 161 | ): BenefitEventWithRates[] => { 162 | return data.map((event) => { 163 | const rateAcquired = rates[event.dateVested]; 164 | const symbolPriceAcquired = symbolPrices[event.dateVested].opening; 165 | 166 | return { 167 | ...event, 168 | rateAcquired: rateAcquired, 169 | symbolPriceAcquired, 170 | }; 171 | }); 172 | }; 173 | 174 | interface FrTaxesForm2074Page510 { 175 | /** Designation */ 176 | "511": string; 177 | /** Date of sale */ 178 | "512": string; 179 | /** Sale price per share, 6 digit precision */ 180 | "514": number; 181 | /** Number of shares sold, 0 digit precision */ 182 | "515": number; 183 | /** 184 | * Total sale price (Number of shares sold * Sale price per share). 185 | * 0 digit precision 186 | */ 187 | "516": number; 188 | /** Cession fees, 0 digit precision */ 189 | "517": number; 190 | /** 191 | * Net sale price (Total sale price - Cession fees) 192 | * 0 digit precision 193 | */ 194 | "518": number; 195 | /** This is a label only */ 196 | "519": undefined; 197 | /** Acquisition value, 2 digits precision */ 198 | "520": number; 199 | /** 200 | * Total acquisition cost (Number of shares sold * Price on acquisition date) 201 | * 0 digit precision 202 | */ 203 | "521": number; 204 | /** Brokerage fees, if any, 0 digit precision */ 205 | "522": number; 206 | /** 207 | * Net acquisition cost (Total acquisition cost - Brokerage fees) 208 | * 0 digit precision 209 | */ 210 | "523": number; 211 | /** 212 | * Net capital gain (Net sale price - Net acquisition cost) 213 | * with `-` if negative 214 | * 0 digit precision 215 | */ 216 | "524": number; 217 | /** 218 | * Is there a capital loss due to share invalidation? 219 | * This is beyond the scope of this tool given it requires a jugement that 220 | * invalidates the shares. If this happens concerned people will know. 221 | * Always false. 222 | */ 223 | "525": boolean; 224 | /** 225 | * Net capital loss for share invalidated. 226 | * This is beyond the scope of this tool, always 0 227 | */ 228 | "526": number; 229 | } 230 | 231 | interface FrTaxesExplain { 232 | /** Related form box */ 233 | box: keyof FrTaxes; 234 | /** 1 line explanation for the event */ 235 | description: string; 236 | /** Detailed explanation for the event */ 237 | taxableEvents: TaxableEventFr[]; 238 | } 239 | 240 | export interface FrTaxes { 241 | /** Explain the computations */ 242 | explanations: FrTaxesExplain[]; 243 | /** RSU acquisition gains above 300K€ */ 244 | "1TT": number; 245 | /** RSU acquisition gains below 300K€, after 50% tax rebate */ 246 | "1TZ": number; 247 | /** Tax acquisition rebate for RSU (50% of the acquisition gains under 300K€, and thus equal to 1TZ) */ 248 | "1WZ": number; 249 | /** 1AJ OR 1BJ for gains as salaries */ 250 | "1AJ": number; 251 | /** Capital gains */ 252 | "3VG": number; 253 | /** Capital losses Year N-1, we do not know about it, but remind it exists */ 254 | "3VH": number; 255 | /** form No. 2074 */ 256 | "Form 2074": { 257 | /** This is the page to declare a sell */ 258 | "Page 510": FrTaxesForm2074Page510[]; 259 | /** Summary of the sales */ 260 | "Page 900": { 261 | /** 262 | * capital gains and losses. 263 | * Computed from the list of all Page 510 264 | */ 265 | "903": { gains: number; losses: number }; 266 | }; 267 | "page 11": { 268 | "1133": { gains: number; losses: number }; 269 | }; 270 | }; 271 | } 272 | 273 | const formatDateForFrTaxes = (date: string) => { 274 | const [year, month, day] = date.split("-"); 275 | return `${day}/${month}/${year}`; 276 | }; 277 | 278 | export const getEmptyTaxes = (): FrTaxes => ({ 279 | explanations: [], 280 | "1TT": 0, 281 | "1TZ": 0, 282 | "1WZ": 0, 283 | "1AJ": 0, 284 | "3VG": 0, 285 | "3VH": 0, 286 | "Form 2074": { 287 | "Page 510": [], 288 | "Page 900": { 289 | "903": { gains: 0, losses: 0 }, 290 | }, 291 | "page 11": { 292 | "1133": { gains: 0, losses: 0 }, 293 | }, 294 | }, 295 | }); 296 | 297 | const getFrTaxesCapitalGain = ( 298 | { 299 | description, 300 | taxableEvents, 301 | }: { 302 | description: string; 303 | taxableEvents: TaxableEventFr[]; 304 | }, 305 | taxes: FrTaxes, 306 | ) => { 307 | if (!taxableEvents.length) { 308 | return taxes; 309 | } 310 | if (taxableEvents.every((event) => event.capitalGain.total === 0)) { 311 | return taxes; 312 | } 313 | 314 | let capitalGainEur = 0; 315 | const newPages: FrTaxes["Form 2074"]["Page 510"] = []; 316 | 317 | taxableEvents.forEach((taxableEvent) => { 318 | if (!taxableEvent.sell) { 319 | // There is no capital gain if there is no sell 320 | return; 321 | } 322 | 323 | if (taxableEvent.capitalGain.total === 0) { 324 | // There is no capital gain if the total is 0 325 | return; 326 | } 327 | 328 | const planType = taxableEvent.planType; 329 | const planDescription = 330 | planType === "SO" ? "Stock Options" : planType === "RS" ? "RSU" : "ESPP"; 331 | 332 | const quantity = floorNumber0Digits(taxableEvent.quantity); 333 | const cell514 = floorNumber6Digits(taxableEvent.sell.eur); 334 | // impots.gouv.fr rounds the total to the nearest euro 335 | const cell516 = roundNumber0Digits(cell514 * quantity); 336 | // There is no small gains, ceiling acquisition value reduces taxable 337 | // amount. 338 | const cell520 = ceilNumber2Digits(taxableEvent.acquisition.valueEur); 339 | // impots.gouv.fr rounds the total to the nearest euro 340 | const cell521 = roundNumber0Digits(cell520 * quantity); 341 | 342 | // Add a new page for Form 2074 343 | const newPage: FrTaxesForm2074Page510 = { 344 | "511": `${taxableEvent.symbol} (${planDescription})`, 345 | "512": formatDateForFrTaxes(taxableEvent.sell.date), 346 | "514": cell514, 347 | "515": quantity, 348 | "516": cell516, 349 | "517": 0, 350 | "518": cell516, 351 | "519": undefined, 352 | "520": cell520, 353 | "521": cell521, 354 | "522": 0, 355 | "523": cell521, 356 | "524": cell516 - cell521, 357 | "525": false, 358 | "526": 0, 359 | }; 360 | 361 | // mutate taxableEvent to adjust capital gain as computed with Form 2074 362 | taxableEvent.capitalGain.total = newPage["524"]; 363 | taxableEvent.capitalGain.perShare = cell514 - cell520; 364 | 365 | capitalGainEur += newPage["524"]; 366 | newPages.push(newPage); 367 | }); 368 | 369 | // Add the capital gain to the total 370 | taxes["3VG"] += capitalGainEur; 371 | 372 | // Add an explanation for the computation 373 | taxes.explanations = [ 374 | ...taxes.explanations, 375 | { 376 | box: "3VG", 377 | description: `${description} (${formatNumber(capitalGainEur)}€ as computed by form 2074)`, 378 | taxableEvents, 379 | }, 380 | ]; 381 | taxes["Form 2074"]["Page 510"] = [ 382 | ...taxes["Form 2074"]["Page 510"], 383 | ...newPages, 384 | ]; 385 | 386 | return taxes; 387 | }; 388 | 389 | /** 390 | * Create a FrTaxable event from an E-Trade export item. 391 | * 392 | * The reason why acquisitionValue, associated rate and acquisitionCost are 393 | * provided is that the valid value for tax administration depends on the plan 394 | * type. 395 | * Every other values are the same for every plans. 396 | */ 397 | const getFrTaxableEventFromGainsAndLossEvent = ( 398 | event: GainAndLossEventWithRates, 399 | { 400 | acquisitionValueUsd, 401 | acquisitionValueRate, 402 | acquisitionCostUsd, 403 | explainAcquisitionValue, 404 | }: { 405 | acquisitionValueUsd: number; 406 | acquisitionValueRate: number; 407 | acquisitionCostUsd: number; 408 | /** 409 | * Explain how the acquisition value was computed. 410 | * 411 | * For instance: 412 | * - "Use symbol price at opening price on day of exercise." 413 | * - "Use symbol price at opening price on day of vesting." 414 | * - "Use sell price as acquisition value given the plan is qualified and the sale is at loss." 415 | */ 416 | explainAcquisitionValue: string; 417 | }, 418 | ): TaxableEventFr => { 419 | const sellPriceEur = floorNumber6Digits(event.proceeds / event.rateSold); 420 | const acquisitionValueEur = floorNumber6Digits( 421 | acquisitionValueUsd / acquisitionValueRate, 422 | ); 423 | const acquisitionCostEur = floorNumber6Digits( 424 | acquisitionCostUsd / event.rateAcquired, 425 | ); 426 | const symbolPriceEur = floorNumber6Digits( 427 | event.symbolPriceAcquired / event.rateAcquired, 428 | ); 429 | 430 | return { 431 | symbol: event.symbol, 432 | planType: event.planType, 433 | qualifiedIn: "fr", 434 | // ETrade Gans And Losses only lists sell events 435 | type: "sell", 436 | date: event.dateSold, 437 | dateGranted: event.dateGranted, 438 | quantity: event.quantity, 439 | sell: { 440 | usd: event.proceeds, 441 | rate: event.rateSold, 442 | eur: sellPriceEur, 443 | date: event.dateSold, 444 | }, 445 | acquisition: { 446 | valueUsd: acquisitionValueUsd, 447 | valueEur: acquisitionValueEur, 448 | costUsd: acquisitionCostUsd, 449 | costEur: acquisitionCostEur, 450 | symbolPrice: event.symbolPriceAcquired, 451 | symbolPriceEur, 452 | rate: event.rateAcquired, 453 | date: event.dateAcquired, 454 | description: explainAcquisitionValue, 455 | dateSymbolPriceAcquired: event.dateSymbolPriceAcquired, 456 | }, 457 | capitalGain: { 458 | perShare: sellPriceEur - acquisitionValueEur, 459 | total: (sellPriceEur - acquisitionValueEur) * event.quantity, 460 | }, 461 | acquisitionGain: { 462 | perShare: 463 | (acquisitionValueEur - acquisitionCostEur) * event.fractionFrIncome, 464 | total: 465 | (acquisitionValueEur - acquisitionCostEur) * 466 | event.fractionFrIncome * 467 | event.quantity, 468 | fractionFr: event.fractionFrIncome, 469 | }, 470 | }; 471 | }; 472 | 473 | export const getFrTaxesForFrQualifiedSo = ( 474 | { 475 | gainsAndLosses, 476 | }: { 477 | gainsAndLosses: GainAndLossEventWithRates[]; 478 | }, 479 | taxes: FrTaxes, 480 | ): FrTaxes => { 481 | const qualifiedSo = gainsAndLosses.filter(isFrQualifiedSo); 482 | // Explanations for the computations 483 | const explanations: FrTaxesExplain[] = []; 484 | // buffers for acquisition gains and capital gains 485 | let acquisitionGainEur = 0; 486 | const taxableEvents: TaxableEventFr[] = []; 487 | 488 | // Process each event 489 | qualifiedSo.forEach((event) => { 490 | // Convert prices to EUR 491 | const sellPriceEur = floorNumber6Digits(event.proceeds / event.rateSold); 492 | const priceOnDayOfAcquisitionEur = floorNumber6Digits( 493 | event.symbolPriceAcquired / event.rateAcquired, 494 | ); 495 | 496 | const isSellToCover = event.dateAcquired === event.dateSold; 497 | const isSellAtLoss = sellPriceEur < priceOnDayOfAcquisitionEur; 498 | 499 | const taxableEvent = getFrTaxableEventFromGainsAndLossEvent( 500 | event, 501 | isSellToCover 502 | ? { 503 | // Sell to cover for qualified stock options: 504 | // Acquisition value is the sell price. 505 | acquisitionValueUsd: event.proceeds, 506 | acquisitionValueRate: event.rateSold, 507 | acquisitionCostUsd: event.acquisitionCost, 508 | explainAcquisitionValue: 509 | "Use sell price as acquisition value given this is a sell to cover.", 510 | } 511 | : isSellAtLoss 512 | ? { 513 | // Sale is at loss, use sell price as acquisition price given the 514 | // plan is qualified 515 | acquisitionValueUsd: event.proceeds, 516 | acquisitionValueRate: event.rateSold, 517 | acquisitionCostUsd: event.acquisitionCost, 518 | explainAcquisitionValue: 519 | "Acquisition value is the sell price given the plan is qualified and the sale is at loss.", 520 | } 521 | : { 522 | // Just use symbol price at opening the day of exercise. 523 | acquisitionValueUsd: event.symbolPriceAcquired, 524 | acquisitionValueRate: event.rateAcquired, 525 | acquisitionCostUsd: event.acquisitionCost, 526 | explainAcquisitionValue: `Use ${event.symbol} price at opening on day of exercise.`, 527 | }, 528 | ); 529 | taxableEvents.push(taxableEvent); 530 | 531 | // Add acquisition gains information 532 | acquisitionGainEur += taxableEvent.acquisitionGain.total; 533 | }); 534 | 535 | const floorAcquisitionGainEur = floorNumber6Digits(acquisitionGainEur); 536 | taxes["1TT"] += floorAcquisitionGainEur; 537 | explanations.push({ 538 | box: "1TT", 539 | description: `Acquisition gains from Qualified SO sales. (${formatNumber(floorAcquisitionGainEur)}€)`, 540 | taxableEvents, 541 | }); 542 | taxes["explanations"] = [...taxes["explanations"], ...explanations]; 543 | // Add capital gains information to Form 2074 544 | taxes = getFrTaxesCapitalGain( 545 | { 546 | description: "Capital gains from FR qualified SO sales.", 547 | taxableEvents, 548 | }, 549 | taxes, 550 | ); 551 | 552 | return taxes; 553 | }; 554 | 555 | export const getFrTaxesForFrQualifiedRsu = ( 556 | { 557 | gainsAndLosses, 558 | }: { 559 | gainsAndLosses: GainAndLossEventWithRates[]; 560 | }, 561 | taxes: FrTaxes, 562 | ): FrTaxes => { 563 | const qualifiedRsu = gainsAndLosses.filter(isFrQualifiedRsu); 564 | // Explanations for the computations 565 | const explanations: FrTaxesExplain[] = []; 566 | // buffers for acquisition gains and capital gains 567 | let acquisitionGainEur = 0; 568 | const taxableEvents: TaxableEventFr[] = []; 569 | 570 | // Process each event 571 | qualifiedRsu.forEach((event) => { 572 | // Convert prices to EUR 573 | const sellPriceEur = floorNumber6Digits(event.proceeds / event.rateSold); 574 | const priceOnDayOfAcquisitionEur = floorNumber6Digits( 575 | event.symbolPriceAcquired / event.rateAcquired, 576 | ); 577 | const isSellToCover = event.dateAcquired === event.dateSold; 578 | const isSellAtLoss = sellPriceEur < priceOnDayOfAcquisitionEur; 579 | 580 | const taxableEvent = getFrTaxableEventFromGainsAndLossEvent( 581 | event, 582 | isSellToCover 583 | ? { 584 | // Sell on the same day. This is treated as a sell to cover. 585 | // Acquisition value is the sell price. 586 | // This event is very unlikely, and the tax rule might be wrong 587 | acquisitionValueUsd: event.proceeds, 588 | acquisitionValueRate: event.rateSold, 589 | acquisitionCostUsd: event.acquisitionCost, 590 | explainAcquisitionValue: [ 591 | "Use sell price as acquisition value given this is a sell to cover.", 592 | "WARNING: implemented tax rule might be wrong for this case.", 593 | "Maybe the acquisition price should be the vesting price instead of the sell price.", 594 | "If you encouter this message, please contact French taxes support.", 595 | ].join("\n"), 596 | } 597 | : isSellAtLoss 598 | ? { 599 | // Sale is at loss, use sell price as acquisition price given this 600 | // is a qualified plan 601 | acquisitionValueUsd: event.proceeds, 602 | acquisitionValueRate: event.rateSold, 603 | acquisitionCostUsd: event.acquisitionCost, 604 | explainAcquisitionValue: 605 | "Acquisition value is the sell price given the plan is qualified and the sale is at loss.", 606 | } 607 | : { 608 | // Just use symbol price at opening the vesting day. 609 | acquisitionValueUsd: event.symbolPriceAcquired, 610 | acquisitionValueRate: event.rateAcquired, 611 | acquisitionCostUsd: event.acquisitionCost, 612 | explainAcquisitionValue: `Use ${event.symbol} price at opening on vesting day.`, 613 | }, 614 | ); 615 | 616 | taxableEvents.push(taxableEvent); 617 | // Add acquisition gains information 618 | acquisitionGainEur += taxableEvent.acquisitionGain.total; 619 | }); 620 | 621 | const floorAcquisitionGainEur = floorNumber6Digits(acquisitionGainEur); 622 | if (acquisitionGainEur > 0) { 623 | const discountableAcquisitionGainEur = Math.min( 624 | floorAcquisitionGainEur, 625 | 300_000, 626 | ); 627 | taxes["1TZ"] += floorNumber6Digits(discountableAcquisitionGainEur / 2); 628 | explanations.push({ 629 | box: "1TZ", 630 | description: `RSU acquisition gains below 300k€ with 50% discount. (${formatNumber(discountableAcquisitionGainEur)} * 50%)`, 631 | taxableEvents, 632 | }); 633 | taxes["1WZ"] += floorNumber6Digits(discountableAcquisitionGainEur / 2); 634 | explanations.push({ 635 | box: "1WZ", 636 | description: `Tax acquisition discount for RSU acquisition gains below 300k€ (${formatNumber(discountableAcquisitionGainEur)} * 50%, see 1TZ for calculation details)`, 637 | taxableEvents: [], 638 | }); 639 | } 640 | if (floorAcquisitionGainEur > 300_000) { 641 | taxes["1TT"] += Math.max(floorAcquisitionGainEur - 300_000, 0); 642 | explanations.push({ 643 | box: "1TT", 644 | description: `RSU acquisition gains above 300k€ (${formatNumber(floorAcquisitionGainEur)} - 300 000€, see 1TZ for calculation details)`, 645 | taxableEvents: [], 646 | }); 647 | } 648 | taxes["explanations"] = [...taxes["explanations"], ...explanations]; 649 | // Add capital gains information to Form 2074 650 | taxes = getFrTaxesCapitalGain( 651 | { 652 | description: "Capital gains from FR qualified RSU sales.", 653 | taxableEvents, 654 | }, 655 | taxes, 656 | ); 657 | 658 | return taxes; 659 | }; 660 | 661 | const isRoughlyEqual = ( 662 | a: number, 663 | b: number, 664 | epsilon: number = 0.001, 665 | ): boolean => a - b < epsilon; 666 | export const getFrTaxesForEspp = ( 667 | { 668 | gainsAndLosses, 669 | }: { 670 | gainsAndLosses: GainAndLossEventWithRates[]; 671 | }, 672 | taxes: FrTaxes, 673 | ): FrTaxes => { 674 | const eventsEspp = gainsAndLosses.filter(isEspp); 675 | const taxableEvents: TaxableEventFr[] = []; 676 | 677 | // Process each event 678 | eventsEspp.forEach((event) => { 679 | // Precision is different from both columns, one is 2 digits, the other is 4. 680 | const hasTaxesBeenCollected = isRoughlyEqual( 681 | event.purchaseDateFairMktValue, 682 | event.adjustedCost, 683 | ); 684 | const taxableEvent: TaxableEventFr = getFrTaxableEventFromGainsAndLossEvent( 685 | event, 686 | { 687 | // ESPP is the only type of plan where the acquisition cost for US 688 | // taxes is the same as the acquisition cost for French taxes. 689 | // 690 | // On first round of ESPP though, the taxes were not collected by 691 | // broker. Broker will report the adjustedCost equal to the 692 | // acquisition cost which is the same behavior as if the taxes were NOT 693 | // collected. To prevent this, use the purchaseDateFairMktValue 694 | // instead of the adjustedCost. 695 | // Any other case should use the adjustedCost given it has more 696 | // precision. 697 | acquisitionValueUsd: hasTaxesBeenCollected 698 | ? event.adjustedCost 699 | : event.purchaseDateFairMktValue, 700 | acquisitionValueRate: event.rateAcquired, 701 | acquisitionCostUsd: event.adjustedCost, 702 | explainAcquisitionValue: hasTaxesBeenCollected 703 | ? "Use ESPP value as defined by e-trade for acquisition value." 704 | : "⚠️ Check you paid taxes for acquisition gains. Fair market value is used given e-trade did not collect taxes but your employer should have.", 705 | }, 706 | ); 707 | 708 | taxableEvents.push(taxableEvent); 709 | }); 710 | 711 | taxes["explanations"] = [ 712 | ...taxes["explanations"], 713 | { 714 | box: "1AJ", 715 | description: 716 | "Acquisition gains from ESPP sales are due the year of acquisition. Already reported by your employer and not yet available in this tool.", 717 | taxableEvents: [], 718 | }, 719 | ]; 720 | 721 | // Add capital gains 722 | taxes = getFrTaxesCapitalGain( 723 | { description: "Capital gains from ESPP sales", taxableEvents }, 724 | taxes, 725 | ); 726 | 727 | return taxes; 728 | }; 729 | 730 | export const getFrTaxesForNonFrQualifiedSo = ( 731 | { 732 | gainsAndLosses, 733 | }: { 734 | gainsAndLosses: GainAndLossEventWithRates[]; 735 | benefits: BenefitEventWithRates[]; 736 | }, 737 | taxes: FrTaxes, 738 | ): FrTaxes => { 739 | // Compute capital gains from gainsAndLosses 740 | const nonQualifiedSo = gainsAndLosses.filter((event) => 741 | isUsQualifiedSo(event), 742 | ); 743 | const taxableEvents: TaxableEventFr[] = []; 744 | 745 | nonQualifiedSo.forEach((event) => { 746 | const isSellToCover = event.dateAcquired === event.dateSold; 747 | 748 | const taxableEvent = getFrTaxableEventFromGainsAndLossEvent( 749 | event, 750 | isSellToCover 751 | ? { 752 | // Sell to cover for stock options: 753 | // Acquisition value is the sell price 754 | acquisitionValueUsd: event.proceeds, 755 | acquisitionValueRate: event.rateSold, 756 | acquisitionCostUsd: event.acquisitionCost, 757 | explainAcquisitionValue: 758 | "Acquisition value is the sell price given this is a sell to cover.", 759 | } 760 | : { 761 | // Use symbol price 762 | acquisitionValueUsd: event.symbolPriceAcquired, 763 | acquisitionValueRate: event.rateAcquired, 764 | acquisitionCostUsd: event.acquisitionCost, 765 | explainAcquisitionValue: `Use ${event.symbol} price at opening on day of exercise.`, 766 | }, 767 | ); 768 | 769 | taxableEvents.push(taxableEvent); 770 | }); 771 | 772 | if (!taxableEvents.length) { 773 | return taxes; 774 | } 775 | 776 | taxes["explanations"] = [ 777 | ...taxes["explanations"], 778 | { 779 | box: "1AJ", 780 | description: 781 | "Acquisition gains from non qualified SO are due at vest time. This is already reported by your employer and not yet calculated by this tool.", 782 | taxableEvents, 783 | }, 784 | ]; 785 | 786 | // Add capital gains information to Form 2074 787 | taxes = getFrTaxesCapitalGain( 788 | { 789 | description: "Capital gains from non FR qualified SO sales.", 790 | taxableEvents, 791 | }, 792 | taxes, 793 | ); 794 | return taxes; 795 | }; 796 | 797 | export const getFrTaxesForNonFrQualifiedRsu = ( 798 | { 799 | gainsAndLosses, 800 | }: { 801 | gainsAndLosses: GainAndLossEventWithRates[]; 802 | benefits: BenefitEventWithRates[]; 803 | }, 804 | taxes: FrTaxes, 805 | ): FrTaxes => { 806 | const nonQualifiedRsu = gainsAndLosses.filter((event) => 807 | isUsQualifiedRsu(event), 808 | ); 809 | const taxableEvents: TaxableEventFr[] = []; 810 | 811 | // Compute capital gains from gainsAndLosses 812 | nonQualifiedRsu.forEach((event) => { 813 | const isSellToCover = event.dateAcquired === event.dateSold; 814 | 815 | const taxableEvent = getFrTaxableEventFromGainsAndLossEvent( 816 | event, 817 | isSellToCover 818 | ? { 819 | // Sell to cover for RSU: 820 | // Acquisition value is the sell price 821 | acquisitionValueUsd: event.proceeds, 822 | acquisitionValueRate: event.rateSold, 823 | acquisitionCostUsd: event.acquisitionCost, 824 | explainAcquisitionValue: [ 825 | "Use sell price as acquisition value given this is a sell to cover.", 826 | "WARNING: implemented tax rule might be wrong for this case.", 827 | "Maybe the acquisition price should be the vesting price instead of the sell price.", 828 | "If you encounter this message, please contact French taxes support.", 829 | ].join("\n"), 830 | } 831 | : { 832 | // Use symbol price 833 | acquisitionValueUsd: event.symbolPriceAcquired, 834 | acquisitionValueRate: event.rateAcquired, 835 | acquisitionCostUsd: event.acquisitionCost, 836 | explainAcquisitionValue: `Use ${event.symbol} price at opening on day of exercise.`, 837 | }, 838 | ); 839 | 840 | taxableEvents.push(taxableEvent); 841 | }); 842 | 843 | if (!taxableEvents.length) { 844 | return taxes; 845 | } 846 | 847 | // FIXME calculate acquisition gain on non-qualified RSUs 848 | // (% of French Origin must be taken into accout) 849 | taxes["explanations"] = [ 850 | ...taxes["explanations"], 851 | { 852 | box: "1AJ", 853 | description: `Acquisition gain from non-qualified RSUs are due at vest time. 854 | That should already have been reported by your employer 855 | and is not yet calculated by this tool.`, 856 | taxableEvents, 857 | }, 858 | ]; 859 | 860 | // Add capital gains information to Form 2074 861 | taxes = getFrTaxesCapitalGain( 862 | { 863 | description: "Capital gains from non FR qualified RSU sales.", 864 | taxableEvents, 865 | }, 866 | taxes, 867 | ); 868 | 869 | return taxes; 870 | }; 871 | 872 | export const applyFrTaxes = ({ 873 | gainsAndLosses, 874 | benefits, 875 | rates, 876 | symbolPrices, 877 | fractions, 878 | }: { 879 | gainsAndLosses: GainAndLossEvent[]; 880 | benefits: BenefitHistoryEvent[]; 881 | rates: { 882 | [date: string]: number; 883 | }; 884 | symbolPrices: { 885 | [symbol: string]: SymbolDailyResponse; 886 | }; 887 | fractions: number[]; 888 | }): FrTaxes => { 889 | return [ 890 | getFrTaxesForFrQualifiedSo, 891 | getFrTaxesForFrQualifiedRsu, 892 | getFrTaxesForEspp, 893 | getFrTaxesForNonFrQualifiedSo, 894 | getFrTaxesForNonFrQualifiedRsu, 895 | ].reduce( 896 | (taxes, fn) => 897 | fn( 898 | { 899 | gainsAndLosses: enrichEtradeGlFrFr(gainsAndLosses, { 900 | rates, 901 | symbolPrices, 902 | fractions, 903 | }), 904 | benefits: enrichEtradeBenefitsFrFr(benefits, { 905 | rates, 906 | symbolPrices, 907 | }), 908 | }, 909 | taxes, 910 | ), 911 | getEmptyTaxes(), 912 | ); 913 | }; 914 | -------------------------------------------------------------------------------- /lib/taxes/taxes-rules-fr.test.ts: -------------------------------------------------------------------------------- 1 | import type { GainAndLossEvent } from "@/lib/etrade/etrade.types"; 2 | import type { GainAndLossEventWithRates } from "./taxes-rules-fr"; 3 | import { 4 | enrichEtradeGlFrFr, 5 | getEmptyTaxes, 6 | getFrTaxesForEspp, 7 | getFrTaxesForFrQualifiedRsu, 8 | getFrTaxesForFrQualifiedSo, 9 | getFrTaxesForNonFrQualifiedRsu, 10 | getFrTaxesForNonFrQualifiedSo, 11 | } from "./taxes-rules-fr"; 12 | import type { SymbolDailyResponse } from "@/lib/symbol-daily.types"; 13 | 14 | describe("enrichEtradeGlFrFr", () => { 15 | it("should work", () => { 16 | const gainsAndLosses: GainAndLossEvent[] = [ 17 | { 18 | symbol: "DDOG", 19 | planType: "SO", 20 | quantity: 10, 21 | proceeds: 117, 22 | adjustedCost: 80, 23 | purchaseDateFairMktValue: 80, 24 | acquisitionCost: 78, 25 | dateGranted: "2021-03-03", 26 | dateAcquired: "2022-03-03", 27 | dateSold: "2022-09-09", 28 | qualifiedIn: "fr", 29 | }, 30 | ]; 31 | const rates = { 32 | "2022-03-03": 1.12, 33 | "2022-09-09": 1.13, 34 | }; 35 | const symbolPrices: { [symbol: string]: SymbolDailyResponse } = { 36 | DDOG: { 37 | "2022-03-03": { opening: 100, closing: 110 }, 38 | "2022-09-09": { opening: 110, closing: 120 }, 39 | }, 40 | }; 41 | const fractions = [1]; 42 | expect( 43 | enrichEtradeGlFrFr(gainsAndLosses, { rates, symbolPrices, fractions }), 44 | ).toEqual([ 45 | { 46 | symbol: "DDOG", 47 | planType: "SO", 48 | quantity: 10, 49 | proceeds: 117, 50 | adjustedCost: 80, 51 | purchaseDateFairMktValue: 80, 52 | acquisitionCost: 78, 53 | dateGranted: "2021-03-03", 54 | dateAcquired: "2022-03-03", 55 | dateSold: "2022-09-09", 56 | qualifiedIn: "fr", 57 | rateAcquired: 1.12, 58 | rateSold: 1.13, 59 | symbolPriceAcquired: 100, 60 | dateSymbolPriceAcquired: undefined, 61 | fractionFrIncome: 1, 62 | }, 63 | ]); 64 | }); 65 | it("should assign 100% if fractionFrIncome does not exist", () => { 66 | const gainsAndLosses: GainAndLossEvent[] = [ 67 | { 68 | symbol: "DDOG", 69 | planType: "SO", 70 | quantity: 10, 71 | proceeds: 117, 72 | adjustedCost: 80, 73 | purchaseDateFairMktValue: 80, 74 | acquisitionCost: 78, 75 | dateGranted: "2021-03-03", 76 | dateAcquired: "2022-03-03", 77 | dateSold: "2022-09-09", 78 | qualifiedIn: "fr", 79 | }, 80 | ]; 81 | const rates = { 82 | "2022-03-03": 1.12, 83 | "2022-09-09": 1.13, 84 | }; 85 | const symbolPrices: { [symbol: string]: SymbolDailyResponse } = { 86 | DDOG: { 87 | "2022-03-03": { opening: 100, closing: 110 }, 88 | "2022-09-09": { opening: 110, closing: 120 }, 89 | }, 90 | }; 91 | const fractions: number[] = []; 92 | expect( 93 | enrichEtradeGlFrFr(gainsAndLosses, { rates, symbolPrices, fractions }), 94 | ).toEqual([ 95 | { 96 | symbol: "DDOG", 97 | planType: "SO", 98 | quantity: 10, 99 | proceeds: 117, 100 | adjustedCost: 80, 101 | purchaseDateFairMktValue: 80, 102 | acquisitionCost: 78, 103 | dateGranted: "2021-03-03", 104 | dateAcquired: "2022-03-03", 105 | dateSold: "2022-09-09", 106 | qualifiedIn: "fr", 107 | rateAcquired: 1.12, 108 | rateSold: 1.13, 109 | symbolPriceAcquired: 100, 110 | dateSymbolPriceAcquired: undefined, 111 | fractionFrIncome: 1, 112 | }, 113 | ]); 114 | }); 115 | it("should use previous day for symbol price if it is not available", () => { 116 | const gainsAndLosses: GainAndLossEvent[] = [ 117 | { 118 | symbol: "DDOG", 119 | planType: "SO", 120 | quantity: 10, 121 | proceeds: 117, 122 | adjustedCost: 80, 123 | purchaseDateFairMktValue: 80, 124 | acquisitionCost: 78, 125 | dateGranted: "2021-03-03", 126 | dateAcquired: "2022-03-03", 127 | dateSold: "2022-09-09", 128 | qualifiedIn: "fr", 129 | }, 130 | ]; 131 | const rates = { 132 | "2022-03-03": 1.12, 133 | "2022-09-09": 1.13, 134 | }; 135 | const symbolPrices: { [symbol: string]: SymbolDailyResponse } = { 136 | DDOG: { 137 | // There is no price for 2022-03-03 138 | "2022-03-02": { opening: 100, closing: 110 }, 139 | "2022-09-09": { opening: 110, closing: 120 }, 140 | }, 141 | }; 142 | const fractions = [1]; 143 | expect( 144 | enrichEtradeGlFrFr(gainsAndLosses, { rates, symbolPrices, fractions }), 145 | ).toEqual([ 146 | { 147 | symbol: "DDOG", 148 | planType: "SO", 149 | quantity: 10, 150 | proceeds: 117, 151 | adjustedCost: 80, 152 | purchaseDateFairMktValue: 80, 153 | acquisitionCost: 78, 154 | dateGranted: "2021-03-03", 155 | dateAcquired: "2022-03-03", 156 | dateSold: "2022-09-09", 157 | qualifiedIn: "fr", 158 | rateAcquired: 1.12, 159 | rateSold: 1.13, 160 | symbolPriceAcquired: 100, 161 | dateSymbolPriceAcquired: "2022-03-02", 162 | fractionFrIncome: 1, 163 | }, 164 | ]); 165 | }); 166 | 167 | it("should use purchaseDateFairMktValue for symbol price if it is not available", () => { 168 | const gainsAndLosses: GainAndLossEvent[] = [ 169 | { 170 | symbol: "DDOG", 171 | planType: "SO", 172 | quantity: 10, 173 | proceeds: 117, 174 | adjustedCost: 333, // This should be the same value as purchaseDateFairMktValue but for the sake of test it is not 175 | purchaseDateFairMktValue: 80, 176 | acquisitionCost: 78, 177 | dateGranted: "2021-03-03", 178 | dateAcquired: "2022-03-03", 179 | dateSold: "2022-09-09", 180 | qualifiedIn: "fr", 181 | }, 182 | ]; 183 | const rates = { 184 | "2022-03-03": 1.12, 185 | "2022-09-09": 1.13, 186 | }; 187 | const symbolPrices: { [symbol: string]: SymbolDailyResponse } = { 188 | DDOG: { 189 | // There is no price for 2022-03-03 190 | "2022-02-02": { opening: 100, closing: 110 }, 191 | "2022-09-09": { opening: 110, closing: 120 }, 192 | }, 193 | }; 194 | 195 | const fractions = [1]; 196 | expect( 197 | enrichEtradeGlFrFr(gainsAndLosses, { rates, symbolPrices, fractions }), 198 | ).toEqual([ 199 | { 200 | symbol: "DDOG", 201 | planType: "SO", 202 | quantity: 10, 203 | proceeds: 117, 204 | adjustedCost: 333, 205 | purchaseDateFairMktValue: 80, 206 | acquisitionCost: 78, 207 | dateGranted: "2021-03-03", 208 | dateAcquired: "2022-03-03", 209 | dateSold: "2022-09-09", 210 | qualifiedIn: "fr", 211 | rateAcquired: 1.12, 212 | rateSold: 1.13, 213 | symbolPriceAcquired: 80, 214 | dateSymbolPriceAcquired: undefined, // cotation date is acquisition date 215 | fractionFrIncome: 1, 216 | }, 217 | ]); 218 | }); 219 | it("should use adjusted cost for symbol price if symbol and purchaseDateFairMktValue are unavailable", () => { 220 | const gainsAndLosses: GainAndLossEvent[] = [ 221 | { 222 | symbol: "DDOG", 223 | planType: "SO", 224 | quantity: 10, 225 | proceeds: 117, 226 | adjustedCost: 80, 227 | purchaseDateFairMktValue: 0, 228 | acquisitionCost: 78, 229 | dateGranted: "2021-03-03", 230 | dateAcquired: "2022-03-03", 231 | dateSold: "2022-09-09", 232 | qualifiedIn: "fr", 233 | }, 234 | ]; 235 | const rates = { 236 | "2022-03-03": 1.12, 237 | "2022-09-09": 1.13, 238 | }; 239 | const symbolPrices: { [symbol: string]: SymbolDailyResponse } = { 240 | DDOG: { 241 | // There is no price for 2022-03-03 242 | "2022-02-02": { opening: 100, closing: 110 }, 243 | "2022-09-09": { opening: 110, closing: 120 }, 244 | }, 245 | }; 246 | 247 | const fractions = [1]; 248 | expect( 249 | enrichEtradeGlFrFr(gainsAndLosses, { rates, symbolPrices, fractions }), 250 | ).toEqual([ 251 | { 252 | symbol: "DDOG", 253 | planType: "SO", 254 | quantity: 10, 255 | proceeds: 117, 256 | adjustedCost: 80, 257 | purchaseDateFairMktValue: 0, 258 | acquisitionCost: 78, 259 | dateGranted: "2021-03-03", 260 | dateAcquired: "2022-03-03", 261 | dateSold: "2022-09-09", 262 | qualifiedIn: "fr", 263 | rateAcquired: 1.12, 264 | rateSold: 1.13, 265 | symbolPriceAcquired: 80, 266 | dateSymbolPriceAcquired: undefined, // cotation date is acquisition date 267 | fractionFrIncome: 1, 268 | }, 269 | ]); 270 | }); 271 | }); 272 | 273 | describe("getFrTaxesForFrQualifiedSo", () => { 274 | it("same day sell", () => { 275 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 276 | { 277 | symbol: "DDOG", 278 | planType: "SO", 279 | quantity: 10, 280 | proceeds: 110, // Sold at 110$ when daily value is 100$ 281 | adjustedCost: 80, 282 | purchaseDateFairMktValue: 80, 283 | acquisitionCost: 20, 284 | dateGranted: "2021-03-03", 285 | dateAcquired: "2022-03-03", 286 | dateSold: "2022-03-03", 287 | qualifiedIn: "fr", 288 | rateAcquired: 1.12, 289 | rateSold: 1.12, 290 | symbolPriceAcquired: 100, 291 | fractionFrIncome: 1, 292 | }, 293 | ]; 294 | 295 | const taxes = getFrTaxesForFrQualifiedSo( 296 | { gainsAndLosses }, 297 | getEmptyTaxes(), 298 | ); 299 | // No capital gain 300 | expect(taxes["3VG"]).toEqual(0); 301 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 302 | 303 | // Acquisition gain 304 | // sellPrice = 110 / 1.12 = 98.2142857143 305 | // cost = 20 / 1.12 = 17.8571428571 306 | // gain per share = 98.2142857143 - 17.8571428571 = 80.3571428571 307 | expect(taxes["1TT"]).toEqual(803.57143); 308 | }); 309 | 310 | it("sell with losses", () => { 311 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 312 | { 313 | symbol: "DDOG", 314 | planType: "SO", 315 | quantity: 10, 316 | proceeds: 90, // Sold at 90$ when acquired at 100$ 317 | adjustedCost: 80, 318 | purchaseDateFairMktValue: 80, 319 | acquisitionCost: 20, 320 | dateGranted: "2021-03-03", 321 | dateAcquired: "2022-03-03", 322 | dateSold: "2022-03-09", 323 | qualifiedIn: "fr", 324 | rateAcquired: 1.12, 325 | rateSold: 1.13, 326 | symbolPriceAcquired: 100, 327 | fractionFrIncome: 1, 328 | }, 329 | ]; 330 | 331 | const taxes = getFrTaxesForFrQualifiedSo( 332 | { gainsAndLosses }, 333 | getEmptyTaxes(), 334 | ); 335 | // No capital gain 336 | expect(taxes["3VG"]).toEqual(0); 337 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 338 | 339 | // Acquisition gain 340 | // sellPrice = 90 / 1.13 = 79.6460176991 341 | // cost = 20 / 1.12 = 17.8571428571 342 | // gain per share = 79.6460176991 - 17.8571428571 = 61.7888748419 343 | expect(taxes["1TT"]).toEqual(617.88875); 344 | }); 345 | 346 | it("sell with gains", () => { 347 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 348 | { 349 | symbol: "DDOG", 350 | planType: "SO", 351 | quantity: 10, 352 | proceeds: 110, // Sold at 110$ when acquired at 100$ 353 | adjustedCost: 80, 354 | purchaseDateFairMktValue: 80, 355 | acquisitionCost: 20, 356 | dateGranted: "2021-03-03", 357 | dateAcquired: "2022-03-03", 358 | dateSold: "2022-03-09", 359 | qualifiedIn: "fr", 360 | rateAcquired: 1.12, 361 | rateSold: 1.13, 362 | symbolPriceAcquired: 100, 363 | fractionFrIncome: 1, 364 | }, 365 | ]; 366 | 367 | const taxes = getFrTaxesForFrQualifiedSo( 368 | { gainsAndLosses }, 369 | getEmptyTaxes(), 370 | ); 371 | 372 | // cost = 20 / 1.12 = 17.8571428571 373 | // acquisitionValue = 100 / 1.12 = 89.2857142857 374 | // sellPrice = 110 / 1.13 = 97.3451327434 375 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 376 | // acquisition gain = 89.2857142857 - 17.8571428571 = 71.4285714286 377 | 378 | // Acquisition gain 379 | expect(taxes["1TT"]).toEqual(714.28572); 380 | 381 | // Capital gain 382 | expect(taxes["3VG"].toFixed(6)).toEqual("80.000000"); 383 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 384 | const page510 = taxes["Form 2074"]["Page 510"][0]; 385 | expect(page510["511"]).toEqual("DDOG (Stock Options)"); 386 | expect(page510["512"]).toEqual("09/03/2022"); 387 | expect(page510["514"].toFixed(6)).toEqual("97.345132"); 388 | expect(page510["515"]).toEqual(10); 389 | expect(page510["516"].toFixed(6)).toEqual("973.000000"); 390 | expect(page510["517"]).toEqual(0); 391 | expect(page510["518"].toFixed(6)).toEqual("973.000000"); 392 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 393 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 394 | expect(page510["522"]).toEqual(0); 395 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 396 | expect(page510["524"].toFixed(6)).toEqual("80.000000"); 397 | expect(page510["525"]).toEqual(false); 398 | expect(page510["526"]).toEqual(0); 399 | }); 400 | }); 401 | 402 | describe("getFrTaxesForFrQualifiedRsu()", () => { 403 | it("same day sell", () => { 404 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 405 | { 406 | symbol: "DDOG", 407 | planType: "RS", 408 | quantity: 10, 409 | proceeds: 110, // Sold at 110$ when daily value is 100$ 410 | adjustedCost: 80, 411 | purchaseDateFairMktValue: 80, 412 | acquisitionCost: 0, 413 | dateGranted: "2021-03-03", 414 | dateAcquired: "2022-03-03", 415 | dateSold: "2022-03-03", 416 | qualifiedIn: "fr", 417 | rateAcquired: 1.12, 418 | rateSold: 1.12, 419 | symbolPriceAcquired: 100, 420 | fractionFrIncome: 1, 421 | }, 422 | ]; 423 | 424 | const taxes = getFrTaxesForFrQualifiedRsu( 425 | { gainsAndLosses }, 426 | getEmptyTaxes(), 427 | ); 428 | // No capital gain 429 | expect(taxes["3VG"]).toEqual(0); 430 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 431 | 432 | // Acquisition gain 433 | // sellPrice = 110 / 1.12 = 98.2142857143 434 | // discount = 98.2142857143 / 2 = 49.1071428571 435 | expect(taxes["1TZ"]).toEqual(491.071425); 436 | expect(taxes["1TT"]).toEqual(0); 437 | expect(taxes["1WZ"]).toEqual(491.071425); 438 | }); 439 | 440 | it("sell with losses", () => { 441 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 442 | { 443 | symbol: "DDOG", 444 | planType: "RS", 445 | quantity: 10, 446 | proceeds: 90, // Sold at 90$ when acquired at 100$ 447 | adjustedCost: 80, 448 | purchaseDateFairMktValue: 80, 449 | acquisitionCost: 0, 450 | dateGranted: "2021-03-03", 451 | dateAcquired: "2022-03-03", 452 | dateSold: "2022-03-09", 453 | qualifiedIn: "fr", 454 | rateAcquired: 1.12, 455 | rateSold: 1.13, 456 | symbolPriceAcquired: 100, 457 | fractionFrIncome: 1, 458 | }, 459 | ]; 460 | 461 | const taxes = getFrTaxesForFrQualifiedRsu( 462 | { gainsAndLosses }, 463 | getEmptyTaxes(), 464 | ); 465 | // No capital gain 466 | expect(taxes["3VG"]).toEqual(0); 467 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 468 | // Acquisition gain 469 | // sellPrice = 90 / 1.13 = 79.6460176991 470 | // discount = 79.6460176991 / 2 = 39.8230088495 471 | expect(taxes["1TZ"]).toEqual(398.230085); 472 | expect(taxes["1TT"]).toEqual(0); 473 | expect(taxes["1WZ"]).toEqual(398.230085); 474 | }); 475 | 476 | it("sell with gains", () => { 477 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 478 | { 479 | symbol: "DDOG", 480 | planType: "RS", 481 | quantity: 10, 482 | proceeds: 110, // Sold at 110$ when acquired at 100$ 483 | adjustedCost: 80, 484 | purchaseDateFairMktValue: 80, 485 | acquisitionCost: 0, 486 | dateGranted: "2021-03-03", 487 | dateAcquired: "2022-03-03", 488 | dateSold: "2022-03-09", 489 | qualifiedIn: "fr", 490 | rateAcquired: 1.12, 491 | rateSold: 1.13, 492 | symbolPriceAcquired: 100, 493 | fractionFrIncome: 1, 494 | }, 495 | ]; 496 | 497 | const taxes = getFrTaxesForFrQualifiedRsu( 498 | { gainsAndLosses }, 499 | getEmptyTaxes(), 500 | ); 501 | 502 | // acquisitionValue = 100 / 1.12 = 89.2857142857 503 | // sellPrice = 110 / 1.13 = 97.3451327434 504 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 505 | // acquisition gain = 89.2857142857 - 0 = 89.2857142857 506 | // discount = 89.2857142857 / 2 = 44.6428571429 507 | // Acquisition gain 508 | expect(taxes["1TZ"]).toEqual(446.42857); 509 | expect(taxes["1TT"]).toEqual(0); 510 | expect(taxes["1WZ"]).toEqual(446.42857); 511 | 512 | // Capital gain 513 | expect(taxes["3VG"].toFixed(6)).toEqual("80.000000"); 514 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 515 | const page510 = taxes["Form 2074"]["Page 510"][0]; 516 | expect(page510["511"]).toEqual("DDOG (RSU)"); 517 | expect(page510["512"]).toEqual("09/03/2022"); 518 | expect(page510["514"].toFixed(6)).toEqual("97.345132"); 519 | expect(page510["515"]).toEqual(10); 520 | expect(page510["516"].toFixed(6)).toEqual("973.000000"); 521 | expect(page510["517"]).toEqual(0); 522 | expect(page510["518"].toFixed(6)).toEqual("973.000000"); 523 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 524 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 525 | expect(page510["522"]).toEqual(0); 526 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 527 | expect(page510["524"].toFixed(6)).toEqual("80.000000"); 528 | expect(page510["525"]).toEqual(false); 529 | expect(page510["526"]).toEqual(0); 530 | }); 531 | 532 | it("fraction FR income < 100%", () => { 533 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 534 | { 535 | symbol: "DDOG", 536 | planType: "RS", 537 | quantity: 10, 538 | proceeds: 110, // Sold at 110$ when acquired at 100$ 539 | adjustedCost: 80, 540 | purchaseDateFairMktValue: 80, 541 | acquisitionCost: 0, 542 | dateGranted: "2021-03-03", 543 | dateAcquired: "2022-03-03", 544 | dateSold: "2022-03-09", 545 | qualifiedIn: "fr", 546 | rateAcquired: 1.12, 547 | rateSold: 1.13, 548 | symbolPriceAcquired: 100, 549 | fractionFrIncome: 0.9, 550 | }, 551 | ]; 552 | 553 | const taxes = getFrTaxesForFrQualifiedRsu( 554 | { gainsAndLosses }, 555 | getEmptyTaxes(), 556 | ); 557 | 558 | // acquisitionValue = 100 / 1.12 = 89.2857142857 559 | // sellPrice = 110 / 1.13 = 97.3451327434 560 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 561 | // acquisition gain = (89.2857142857 - 0) * 90% = 80.3571428571 562 | // discount = 80.3571428571 / 2 = 40.1785714286 563 | // Acquisition gain 564 | expect(taxes["1TZ"]).toEqual(401.785713); 565 | expect(taxes["1TT"]).toEqual(0); 566 | expect(taxes["1WZ"]).toEqual(401.785713); 567 | }); 568 | }); 569 | 570 | describe("getFrTaxesForEspp", () => { 571 | it("capital loss", () => { 572 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 573 | { 574 | symbol: "DDOG", 575 | planType: "ESPP", 576 | quantity: 10, 577 | proceeds: 90, // Sold at 90$ when acquired at 100$ 578 | adjustedCost: 100, 579 | purchaseDateFairMktValue: 100, 580 | acquisitionCost: 80, 581 | dateGranted: "2021-03-03", 582 | dateAcquired: "2022-03-03", 583 | dateSold: "2022-03-09", 584 | qualifiedIn: "fr", 585 | rateAcquired: 1.12, 586 | rateSold: 1.13, 587 | symbolPriceAcquired: 100, 588 | fractionFrIncome: 1, 589 | }, 590 | ]; 591 | 592 | const taxes = getFrTaxesForEspp({ gainsAndLosses }, getEmptyTaxes()); 593 | // Capital loss 594 | // acquisitionValue = 100 / 1.12 = 89.2857142857 595 | // sellPrice = 90 / 1.13 = 79.6460176991 596 | // capital loss = 79.6460176991 - 89.2857142857 = -9.6396965866 597 | expect(taxes["3VG"].toFixed(6)).toEqual("-97.000000"); 598 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 599 | const page510 = taxes["Form 2074"]["Page 510"][0]; 600 | expect(page510["511"]).toEqual("DDOG (ESPP)"); 601 | expect(page510["512"]).toEqual("09/03/2022"); 602 | expect(page510["514"].toFixed(6)).toEqual("79.646017"); 603 | expect(page510["515"]).toEqual(10); 604 | expect(page510["516"].toFixed(6)).toEqual("796.000000"); 605 | expect(page510["517"]).toEqual(0); 606 | expect(page510["518"].toFixed(6)).toEqual("796.000000"); 607 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 608 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 609 | expect(page510["522"]).toEqual(0); 610 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 611 | expect(page510["524"].toFixed(6)).toEqual("-97.000000"); 612 | expect(page510["525"]).toEqual(false); 613 | expect(page510["526"]).toEqual(0); 614 | // Acquisition gain 615 | expect(taxes["1AJ"]).toEqual(0); 616 | expect(taxes["1TZ"]).toEqual(0); 617 | expect(taxes["1TT"]).toEqual(0); 618 | expect(taxes["1WZ"]).toEqual(0); 619 | }); 620 | it("capital gain", () => { 621 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 622 | { 623 | symbol: "DDOG", 624 | planType: "ESPP", 625 | quantity: 10, 626 | proceeds: 110, // Sold at 110$ when acquired at 100$ 627 | adjustedCost: 100, 628 | purchaseDateFairMktValue: 100, 629 | acquisitionCost: 80, 630 | dateGranted: "2021-03-03", 631 | dateAcquired: "2022-03-03", 632 | dateSold: "2022-03-09", 633 | qualifiedIn: "fr", 634 | rateAcquired: 1.12, 635 | rateSold: 1.13, 636 | symbolPriceAcquired: 100, 637 | fractionFrIncome: 1, 638 | }, 639 | ]; 640 | 641 | const taxes = getFrTaxesForEspp({ gainsAndLosses }, getEmptyTaxes()); 642 | // Capital gain 643 | // acquisitionValue = 100 / 1.12 = 89.2857142857 644 | // sellPrice = 110 / 1.13 = 97.3451327434 645 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 646 | expect(taxes["3VG"].toFixed(6)).toEqual("80.000000"); 647 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 648 | const page510 = taxes["Form 2074"]["Page 510"][0]; 649 | expect(page510["511"]).toEqual("DDOG (ESPP)"); 650 | expect(page510["512"]).toEqual("09/03/2022"); 651 | expect(page510["514"].toFixed(6)).toEqual("97.345132"); 652 | expect(page510["515"]).toEqual(10); 653 | expect(page510["516"].toFixed(6)).toEqual("973.000000"); 654 | expect(page510["517"]).toEqual(0); 655 | expect(page510["518"].toFixed(6)).toEqual("973.000000"); 656 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 657 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 658 | expect(page510["522"]).toEqual(0); 659 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 660 | expect(page510["524"].toFixed(6)).toEqual("80.000000"); 661 | expect(page510["525"]).toEqual(false); 662 | expect(page510["526"]).toEqual(0); 663 | // Acquisition gain 664 | expect(taxes["1AJ"]).toEqual(0); 665 | expect(taxes["1TZ"]).toEqual(0); 666 | expect(taxes["1TT"]).toEqual(0); 667 | expect(taxes["1WZ"]).toEqual(0); 668 | }); 669 | it("Simulate first round of ESPP", () => { 670 | // On first round of ESPP the taxes were not collected by E-Trade 671 | // E-Trade will report the adjustedCost equal to the acquisition cost 672 | // which is the same behavior as if the taxes were NOT collected. 673 | // To prevent this, use the purchaseDateFairMktValue instead of the adjustedCost 674 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 675 | { 676 | symbol: "DDOG", 677 | planType: "ESPP", 678 | quantity: 10, 679 | proceeds: 90, // Sold at 90$ when acquired at 100$ 680 | adjustedCost: 100, 681 | purchaseDateFairMktValue: 100, 682 | acquisitionCost: 80, 683 | dateGranted: "2021-03-03", 684 | dateAcquired: "2022-03-03", 685 | dateSold: "2022-03-09", 686 | qualifiedIn: "fr", 687 | rateAcquired: 1.12, 688 | rateSold: 1.13, 689 | symbolPriceAcquired: 100, 690 | fractionFrIncome: 1, 691 | }, 692 | ]; 693 | 694 | const taxes = getFrTaxesForEspp({ gainsAndLosses }, getEmptyTaxes()); 695 | // Capital loss 696 | // acquisitionValue = 100 / 1.12 = 89.2857142857 697 | // sellPrice = 90 / 1.13 = 79.6460176991 698 | // capital loss = 79.6460176991 - 89.2857142857 = -9.6396965866 699 | expect(taxes["3VG"].toFixed(6)).toEqual("-97.000000"); 700 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 701 | const page510 = taxes["Form 2074"]["Page 510"][0]; 702 | expect(page510["511"]).toEqual("DDOG (ESPP)"); 703 | expect(page510["512"]).toEqual("09/03/2022"); 704 | expect(page510["514"].toFixed(6)).toEqual("79.646017"); 705 | expect(page510["515"]).toEqual(10); 706 | expect(page510["516"].toFixed(6)).toEqual("796.000000"); 707 | expect(page510["517"]).toEqual(0); 708 | expect(page510["518"].toFixed(6)).toEqual("796.000000"); 709 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 710 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 711 | expect(page510["522"]).toEqual(0); 712 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 713 | expect(page510["524"].toFixed(6)).toEqual("-97.000000"); 714 | expect(page510["525"]).toEqual(false); 715 | expect(page510["526"]).toEqual(0); 716 | // Acquisition gain 717 | expect(taxes["1AJ"]).toEqual(0); 718 | expect(taxes["1TZ"]).toEqual(0); 719 | expect(taxes["1TT"]).toEqual(0); 720 | expect(taxes["1WZ"]).toEqual(0); 721 | }); 722 | }); 723 | 724 | describe("getFrTaxesForNonFrQualifiedSo", () => { 725 | it("same day sell", () => { 726 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 727 | { 728 | symbol: "DDOG", 729 | planType: "SO", 730 | quantity: 10, 731 | proceeds: 110, // Sold at 110$ when daily value is 100$ 732 | adjustedCost: 80, 733 | purchaseDateFairMktValue: 80, 734 | acquisitionCost: 0, 735 | dateGranted: "2021-03-03", 736 | dateAcquired: "2022-03-03", 737 | dateSold: "2022-03-03", 738 | qualifiedIn: "us", 739 | rateAcquired: 1.12, 740 | rateSold: 1.12, 741 | symbolPriceAcquired: 100, 742 | fractionFrIncome: 1, 743 | }, 744 | ]; 745 | const taxes = getFrTaxesForNonFrQualifiedSo( 746 | { gainsAndLosses, benefits: [] }, 747 | getEmptyTaxes(), 748 | ); 749 | // No capital gain 750 | expect(taxes["3VG"]).toEqual(0); 751 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 752 | }); 753 | it("capital loss", () => { 754 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 755 | { 756 | symbol: "DDOG", 757 | planType: "SO", 758 | quantity: 10, 759 | proceeds: 90, // Sold at 90$ when acquired at 100$ 760 | adjustedCost: 80, 761 | purchaseDateFairMktValue: 80, 762 | acquisitionCost: 0, 763 | dateGranted: "2021-03-03", 764 | dateAcquired: "2022-03-03", 765 | dateSold: "2022-03-09", 766 | qualifiedIn: "us", 767 | rateAcquired: 1.12, 768 | rateSold: 1.13, 769 | symbolPriceAcquired: 100, 770 | fractionFrIncome: 1, 771 | }, 772 | ]; 773 | const taxes = getFrTaxesForNonFrQualifiedSo( 774 | { gainsAndLosses, benefits: [] }, 775 | getEmptyTaxes(), 776 | ); 777 | // Capital loss 778 | // acquisitionValue = 100 / 1.12 = 89.2857142857 779 | // sellPrice = 90 / 1.13 = 79.6460176991 780 | // capital loss = 79.6460176991 - 89.2857142857 = -9.6396965866 781 | expect(taxes["3VG"].toFixed(6)).toEqual("-97.000000"); 782 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 783 | const page510 = taxes["Form 2074"]["Page 510"][0]; 784 | expect(page510["511"]).toEqual("DDOG (Stock Options)"); 785 | expect(page510["512"]).toEqual("09/03/2022"); 786 | expect(page510["514"].toFixed(6)).toEqual("79.646017"); 787 | expect(page510["515"]).toEqual(10); 788 | expect(page510["516"].toFixed(6)).toEqual("796.000000"); 789 | expect(page510["517"]).toEqual(0); 790 | expect(page510["518"].toFixed(6)).toEqual("796.000000"); 791 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 792 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 793 | expect(page510["522"]).toEqual(0); 794 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 795 | expect(page510["524"].toFixed(6)).toEqual("-97.000000"); 796 | expect(page510["525"]).toEqual(false); 797 | expect(page510["526"]).toEqual(0); 798 | }); 799 | it("capital gain", () => { 800 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 801 | { 802 | symbol: "DDOG", 803 | planType: "SO", 804 | quantity: 10, 805 | proceeds: 110, // Sold at 110$ when acquired at 100$ 806 | adjustedCost: 80, 807 | purchaseDateFairMktValue: 80, 808 | acquisitionCost: 0, 809 | dateGranted: "2021-03-03", 810 | dateAcquired: "2022-03-03", 811 | dateSold: "2022-03-09", 812 | qualifiedIn: "us", 813 | rateAcquired: 1.12, 814 | rateSold: 1.13, 815 | symbolPriceAcquired: 100, 816 | fractionFrIncome: 1, 817 | }, 818 | ]; 819 | const taxes = getFrTaxesForNonFrQualifiedSo( 820 | { gainsAndLosses, benefits: [] }, 821 | getEmptyTaxes(), 822 | ); 823 | // Capital gain 824 | // acquisitionValue = 100 / 1.12 = 89.2857142857 825 | // sellPrice = 110 / 1.13 = 97.3451327434 826 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 827 | expect(taxes["3VG"].toFixed(6)).toEqual("80.000000"); 828 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 829 | const page510 = taxes["Form 2074"]["Page 510"][0]; 830 | expect(page510["511"]).toEqual("DDOG (Stock Options)"); 831 | expect(page510["512"]).toEqual("09/03/2022"); 832 | expect(page510["514"].toFixed(6)).toEqual("97.345132"); 833 | expect(page510["515"]).toEqual(10); 834 | expect(page510["516"].toFixed(6)).toEqual("973.000000"); 835 | expect(page510["517"]).toEqual(0); 836 | expect(page510["518"].toFixed(6)).toEqual("973.000000"); 837 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 838 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 839 | expect(page510["522"]).toEqual(0); 840 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 841 | expect(page510["524"].toFixed(6)).toEqual("80.000000"); 842 | expect(page510["525"]).toEqual(false); 843 | expect(page510["526"]).toEqual(0); 844 | }); 845 | }); 846 | 847 | describe("getFrTaxesForNonFrQualifiedRsu", () => { 848 | it("same day sell", () => { 849 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 850 | { 851 | symbol: "DDOG", 852 | planType: "RS", 853 | quantity: 10, 854 | proceeds: 110, // Sold at 110$ when daily value is 100$ 855 | adjustedCost: 80, 856 | purchaseDateFairMktValue: 80, 857 | acquisitionCost: 0, 858 | dateGranted: "2021-03-03", 859 | dateAcquired: "2022-03-03", 860 | dateSold: "2022-03-03", 861 | qualifiedIn: "us", 862 | rateAcquired: 1.12, 863 | rateSold: 1.12, 864 | symbolPriceAcquired: 100, 865 | fractionFrIncome: 1, 866 | }, 867 | ]; 868 | const taxes = getFrTaxesForNonFrQualifiedRsu( 869 | { gainsAndLosses, benefits: [] }, 870 | getEmptyTaxes(), 871 | ); 872 | // No capital gain 873 | expect(taxes["3VG"]).toEqual(0); 874 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(0); 875 | }); 876 | it("capital loss", () => { 877 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 878 | { 879 | symbol: "DDOG", 880 | planType: "RS", 881 | quantity: 10, 882 | proceeds: 90, // Sold at 90$ when acquired at 100$ 883 | adjustedCost: 0, 884 | purchaseDateFairMktValue: 100.6, 885 | acquisitionCost: 0, 886 | dateGranted: "2021-03-03", 887 | dateAcquired: "2022-03-03", 888 | dateSold: "2022-03-09", 889 | qualifiedIn: "us", 890 | rateAcquired: 1.12, 891 | rateSold: 1.13, 892 | symbolPriceAcquired: 100, 893 | fractionFrIncome: 1, 894 | }, 895 | ]; 896 | const taxes = getFrTaxesForNonFrQualifiedRsu( 897 | { gainsAndLosses, benefits: [] }, 898 | getEmptyTaxes(), 899 | ); 900 | // Capital loss 901 | // acquisitionValue = 100 / 1.12 = 89.2857142857 902 | // sellPrice = 90 / 1.13 = 79.6460176991 903 | // capital loss = 79.6460176991 - 89.2857142857 = -9.6396965866 904 | expect(taxes["3VG"].toFixed(6)).toEqual("-97.000000"); 905 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 906 | const page510 = taxes["Form 2074"]["Page 510"][0]; 907 | expect(page510["511"]).toEqual("DDOG (RSU)"); 908 | expect(page510["512"]).toEqual("09/03/2022"); 909 | expect(page510["514"].toFixed(6)).toEqual("79.646017"); 910 | expect(page510["515"]).toEqual(10); 911 | expect(page510["516"].toFixed(6)).toEqual("796.000000"); 912 | expect(page510["517"]).toEqual(0); 913 | expect(page510["518"].toFixed(6)).toEqual("796.000000"); 914 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 915 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 916 | expect(page510["522"]).toEqual(0); 917 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 918 | expect(page510["524"].toFixed(6)).toEqual("-97.000000"); 919 | expect(page510["525"]).toEqual(false); 920 | expect(page510["526"]).toEqual(0); 921 | }); 922 | it("capital gain", () => { 923 | const gainsAndLosses: GainAndLossEventWithRates[] = [ 924 | { 925 | symbol: "DDOG", 926 | planType: "RS", 927 | quantity: 10, 928 | proceeds: 110, // Sold at 110$ when acquired at 100$ 929 | adjustedCost: 80, 930 | purchaseDateFairMktValue: 80, 931 | acquisitionCost: 0, 932 | dateGranted: "2021-03-03", 933 | dateAcquired: "2022-03-03", 934 | dateSold: "2022-03-09", 935 | qualifiedIn: "us", 936 | rateAcquired: 1.12, 937 | rateSold: 1.13, 938 | symbolPriceAcquired: 100, 939 | fractionFrIncome: 1, 940 | }, 941 | ]; 942 | const taxes = getFrTaxesForNonFrQualifiedRsu( 943 | { gainsAndLosses, benefits: [] }, 944 | getEmptyTaxes(), 945 | ); 946 | // Capital gain 947 | // acquisitionValue = 100 / 1.12 = 89.2857142857 948 | // sellPrice = 110 / 1.13 = 97.3451327434 949 | // capital gain = 97.3451327434 - 89.2857142857 = 8.0594184577 950 | expect(taxes["3VG"].toFixed(6)).toEqual("80.000000"); 951 | expect(taxes["Form 2074"]["Page 510"]).toHaveLength(1); 952 | const page510 = taxes["Form 2074"]["Page 510"][0]; 953 | expect(page510["511"]).toEqual("DDOG (RSU)"); 954 | expect(page510["512"]).toEqual("09/03/2022"); 955 | expect(page510["514"].toFixed(6)).toEqual("97.345132"); 956 | expect(page510["515"]).toEqual(10); 957 | expect(page510["516"].toFixed(6)).toEqual("973.000000"); 958 | expect(page510["517"]).toEqual(0); 959 | expect(page510["518"].toFixed(6)).toEqual("973.000000"); 960 | expect(page510["520"].toFixed(6)).toEqual("89.290000"); 961 | expect(page510["521"].toFixed(6)).toEqual("893.000000"); 962 | expect(page510["522"]).toEqual(0); 963 | expect(page510["523"].toFixed(6)).toEqual("893.000000"); 964 | expect(page510["524"].toFixed(6)).toEqual("80.000000"); 965 | expect(page510["525"]).toEqual(false); 966 | expect(page510["526"]).toEqual(0); 967 | }); 968 | }); 969 | --------------------------------------------------------------------------------