1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

fix merge confict

This commit is contained in:
Tyler Myracle 2024-01-20 16:44:08 -06:00
commit 81c28067bc
16 changed files with 258 additions and 54 deletions

View file

@ -37,7 +37,9 @@ const WithAuth = function ({ children }: PropsWithChildren) {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (!session && status === 'unauthenticated') { if (status === 'loading') return
if (!session) {
router.push('/login') router.push('/login')
} }
}, [session, status, router]) }, [session, status, router])

View file

@ -1,4 +1,5 @@
import type { User } from '@prisma/client' import type { User } from '@prisma/client'
import { InvestmentTransactionCategory } from '@prisma/client'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { createLogger, transports } from 'winston' import { createLogger, transports } from 'winston'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
@ -131,6 +132,7 @@ describe('balance sync strategies', () => {
quantity: 10, quantity: 10,
price: 10, price: 10,
plaidType: 'buy', plaidType: 'buy',
category: InvestmentTransactionCategory.buy,
}, },
{ {
date: DateTime.fromISO('2023-02-04').toJSDate(), date: DateTime.fromISO('2023-02-04').toJSDate(),
@ -140,6 +142,7 @@ describe('balance sync strategies', () => {
quantity: 5, quantity: 5,
price: 10, price: 10,
plaidType: 'sell', plaidType: 'sell',
category: InvestmentTransactionCategory.sell,
}, },
{ {
date: DateTime.fromISO('2023-02-04').toJSDate(), date: DateTime.fromISO('2023-02-04').toJSDate(),
@ -147,6 +150,7 @@ describe('balance sync strategies', () => {
amount: 50, amount: 50,
quantity: 50, quantity: 50,
price: 1, price: 1,
category: InvestmentTransactionCategory.other,
}, },
], ],
}, },

View file

@ -1,5 +1,5 @@
import type { User } from '@prisma/client' import type { User } from '@prisma/client'
import { Prisma, PrismaClient } from '@prisma/client' import { InvestmentTransactionCategory, Prisma, PrismaClient } from '@prisma/client'
import { createLogger, transports } from 'winston' import { createLogger, transports } from 'winston'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import type { import type {
@ -307,6 +307,7 @@ describe('insight service', () => {
price: 100, price: 100,
plaidType: 'buy', plaidType: 'buy',
plaidSubtype: 'buy', plaidSubtype: 'buy',
category: InvestmentTransactionCategory.buy,
}, },
{ {
accountId: account.id, accountId: account.id,
@ -318,6 +319,7 @@ describe('insight service', () => {
price: 200, price: 200,
plaidType: 'buy', plaidType: 'buy',
plaidSubtype: 'buy', plaidSubtype: 'buy',
category: InvestmentTransactionCategory.buy,
}, },
{ {
accountId: account.id, accountId: account.id,
@ -329,6 +331,7 @@ describe('insight service', () => {
price: 0, price: 0,
plaidType: 'cash', plaidType: 'cash',
plaidSubtype: 'dividend', plaidSubtype: 'dividend',
category: InvestmentTransactionCategory.dividend,
}, },
{ {
accountId: account.id, accountId: account.id,
@ -340,6 +343,7 @@ describe('insight service', () => {
price: 0, price: 0,
plaidType: 'cash', plaidType: 'cash',
plaidSubtype: 'dividend', plaidSubtype: 'dividend',
category: InvestmentTransactionCategory.dividend,
}, },
], ],
}) })

View file

@ -1,5 +1,5 @@
import type { PrismaClient, User } from '@prisma/client' import type { PrismaClient, User } from '@prisma/client'
import { Prisma } from '@prisma/client' import { InvestmentTransactionCategory, Prisma } from '@prisma/client'
import _ from 'lodash' import _ from 'lodash'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { parseCsv } from './csv' import { parseCsv } from './csv'
@ -20,6 +20,14 @@ const portfolios: Record<string, Partial<Prisma.AccountUncheckedCreateInput>> =
}, },
} }
const investmentTransactionCategoryByType: Record<string, InvestmentTransactionCategory> = {
BUY: InvestmentTransactionCategory.buy,
SELL: InvestmentTransactionCategory.sell,
DIVIDEND: InvestmentTransactionCategory.dividend,
DEPOSIT: InvestmentTransactionCategory.transfer,
WITHDRAW: InvestmentTransactionCategory.transfer,
}
export async function createTestInvestmentAccount( export async function createTestInvestmentAccount(
prisma: PrismaClient, prisma: PrismaClient,
user: User, user: User,
@ -35,7 +43,7 @@ export async function createTestInvestmentAccount(
join(__dirname, `../test-data/${portfolio}/holdings.csv`) join(__dirname, `../test-data/${portfolio}/holdings.csv`)
) )
const [_deleted, ...securities] = await prisma.$transaction([ const [, ...securities] = await prisma.$transaction([
prisma.security.deleteMany({ prisma.security.deleteMany({
where: { where: {
symbol: { symbol: {
@ -72,7 +80,7 @@ export async function createTestInvestmentAccount(
.value(), .value(),
]) ])
const account = await prisma.account.create({ return prisma.account.create({
data: { data: {
...portfolios[portfolio], ...portfolios[portfolio],
userId: user.id, userId: user.id,
@ -128,12 +136,13 @@ export async function createTestInvestmentAccount(
: it.type === 'SELL' : it.type === 'SELL'
? 'sell' ? 'sell'
: undefined, : undefined,
category:
investmentTransactionCategoryByType[it.type] ??
InvestmentTransactionCategory.other,
} }
}), }),
}, },
}, },
}, },
}) })
return account
} }

View file

@ -109,6 +109,7 @@ app.use(
app.use('/v1/stripe', express.raw({ type: 'application/json' })) app.use('/v1/stripe', express.raw({ type: 'application/json' }))
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// ========================================= // =========================================
// API ⬇️ // API ⬇️

View file

@ -193,7 +193,7 @@ export function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {
<MenuPopover <MenuPopover
isHeader={false} isHeader={false}
icon={<ProfileCircle />} icon={<ProfileCircle />}
buttonClassName="w-12 h-12 rounded-full" placement={'right-end'}
/> />
</div> </div>
</nav> </nav>

View file

@ -6,24 +6,19 @@ import {
RiShutDownLine as LogoutIcon, RiShutDownLine as LogoutIcon,
RiDatabase2Line, RiDatabase2Line,
} from 'react-icons/ri' } from 'react-icons/ri'
import classNames from 'classnames'
export function MenuPopover({ export function MenuPopover({
icon, icon,
buttonClassName,
placement = 'top-end', placement = 'top-end',
isHeader, isHeader,
}: { }: {
icon: JSX.Element icon: JSX.Element
buttonClassName?: string
placement?: ComponentProps<typeof Menu.Item>['placement'] placement?: ComponentProps<typeof Menu.Item>['placement']
isHeader: boolean isHeader: boolean
}) { }) {
return ( return (
<Menu> <Menu>
<Menu.Button variant="icon" className={classNames(buttonClassName)}> <Menu.Button variant="profileIcon">{icon}</Menu.Button>
{icon}
</Menu.Button>
<Menu.Items <Menu.Items
placement={placement} placement={placement}
className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'} className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'}

View file

@ -10,6 +10,7 @@ const ButtonVariants = Object.freeze({
input: 'px-4 py-2 rounded text-base bg-transparent text-gray-25 border border-gray-200 shadow focus:bg-gray-500 focus:ring-gray-400', input: 'px-4 py-2 rounded text-base bg-transparent text-gray-25 border border-gray-200 shadow focus:bg-gray-500 focus:ring-gray-400',
link: 'px-4 py-2 rounded text-base text-cyan hover:text-cyan-400 focus:text-cyan-300 focus:ring-cyan', link: 'px-4 py-2 rounded text-base text-cyan hover:text-cyan-400 focus:text-cyan-300 focus:ring-cyan',
icon: 'p-0 w-8 h-8 rounded text-2xl text-gray-25 hover:bg-gray-300 focus:bg-gray-200 focus:ring-gray-400', icon: 'p-0 w-8 h-8 rounded text-2xl text-gray-25 hover:bg-gray-300 focus:bg-gray-200 focus:ring-gray-400',
profileIcon: 'p-0 w-12 h-12 rounded text-2xl text-gray-25',
danger: 'px-4 py-2 rounded text-base bg-red text-gray-700 shadow hover:bg-red-400 focus:bg-red-400 focus:ring-red', danger: 'px-4 py-2 rounded text-base bg-red text-gray-700 shadow hover:bg-red-400 focus:bg-red-400 focus:ring-red',
warn: 'px-4 py-2 rounded text-base bg-gray-500 text-red-500 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-red', warn: 'px-4 py-2 rounded text-base bg-gray-500 text-red-500 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-red',
}) })

View file

@ -69,9 +69,8 @@ export class InvestmentTransactionBalanceSyncStrategy extends BalanceSyncStrateg
WHERE WHERE
it.account_id = ${pAccountId} it.account_id = ${pAccountId}
AND it.date BETWEEN ${pStart} AND now() AND it.date BETWEEN ${pStart} AND now()
AND ( -- filter for transactions that modify a position -- filter for transactions that modify a position
it.plaid_type IN ('buy', 'sell', 'transfer') AND it.category IN ('buy', 'sell', 'transfer')
)
GROUP BY GROUP BY
1, 2 1, 2
) it ON it.security_id = s.id AND it.date = d.date ) it ON it.security_id = s.id AND it.date = d.date

View file

@ -242,11 +242,7 @@ export class AccountQueryService implements IAccountQueryService {
it.account_id = ANY(${pAccountIds}) it.account_id = ANY(${pAccountIds})
AND it.date BETWEEN sd.start_date AND ${pEnd} AND it.date BETWEEN sd.start_date AND ${pEnd}
-- filter for investment_transactions that represent external flows -- filter for investment_transactions that represent external flows
AND ( AND it.category = 'transfer'
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer'))
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
)
GROUP BY GROUP BY
1, 2 1, 2
), external_flow_totals AS ( ), external_flow_totals AS (

View file

@ -312,6 +312,7 @@ export class InsightService implements IInsightService {
{ {
plaidSubtype: 'dividend', plaidSubtype: 'dividend',
}, },
{ category: 'dividend' },
], ],
}, },
}), }),
@ -737,11 +738,7 @@ export class InsightService implements IInsightService {
LEFT JOIN account a ON a.id = it.account_id LEFT JOIN account a ON a.id = it.account_id
WHERE WHERE
it.account_id = ${accountId} it.account_id = ${accountId}
AND ( AND it.category = 'transfer'
(it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))
OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))
OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))
)
-- Exclude any contributions made prior to the start date since balances will be 0 -- Exclude any contributions made prior to the start date since balances will be 0
AND (a.start_date is NULL OR it.date >= a.start_date) AND (a.start_date is NULL OR it.date >= a.start_date)
GROUP BY 1 GROUP BY 1

View file

@ -14,7 +14,8 @@ import type {
LiabilitiesObject as PlaidLiabilities, LiabilitiesObject as PlaidLiabilities,
PlaidApi, PlaidApi,
} from 'plaid' } from 'plaid'
import { Prisma } from '@prisma/client' import { InvestmentTransactionSubtype, InvestmentTransactionType } from 'plaid'
import { Prisma, InvestmentTransactionCategory } from '@prisma/client'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import _, { chunk } from 'lodash' import _, { chunk } from 'lodash'
import { ErrorUtil, PlaidUtil } from '@maybe-finance/server/shared' import { ErrorUtil, PlaidUtil } from '@maybe-finance/server/shared'
@ -548,7 +549,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
...chunk(investmentTransactions, 1_000).map( ...chunk(investmentTransactions, 1_000).map(
(chunk) => (chunk) =>
this.prisma.$executeRaw` this.prisma.$executeRaw`
INSERT INTO investment_transaction (account_id, security_id, plaid_investment_transaction_id, date, name, amount, fees, quantity, price, currency_code, plaid_type, plaid_subtype) INSERT INTO investment_transaction (account_id, security_id, plaid_investment_transaction_id, date, name, amount, fees, quantity, price, currency_code, plaid_type, plaid_subtype, category)
VALUES VALUES
${Prisma.join( ${Prisma.join(
chunk.map( chunk.map(
@ -584,7 +585,11 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
${DbUtil.toDecimal(price)}, ${DbUtil.toDecimal(price)},
${currencyCode}, ${currencyCode},
${type}, ${type},
${subtype} ${subtype},
${this.getInvestmentTransactionCategoryByPlaidType(
type,
subtype
)}
)` )`
} }
) )
@ -602,6 +607,7 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
currency_code = EXCLUDED.currency_code, currency_code = EXCLUDED.currency_code,
plaid_type = EXCLUDED.plaid_type, plaid_type = EXCLUDED.plaid_type,
plaid_subtype = EXCLUDED.plaid_subtype; plaid_subtype = EXCLUDED.plaid_subtype;
category = EXCLUDED.category;
` `
), ),
@ -669,6 +675,63 @@ export class PlaidETL implements IETL<Connection, PlaidRawData, PlaidData> {
] ]
} }
private getInvestmentTransactionCategoryByPlaidType = (
type: InvestmentTransactionType,
subType: InvestmentTransactionSubtype
): InvestmentTransactionCategory => {
if (type === InvestmentTransactionType.Buy) {
return InvestmentTransactionCategory.buy
}
if (type === InvestmentTransactionType.Sell) {
return InvestmentTransactionCategory.sell
}
if (
[
InvestmentTransactionSubtype.Dividend,
InvestmentTransactionSubtype.QualifiedDividend,
InvestmentTransactionSubtype.NonQualifiedDividend,
].includes(subType)
) {
return InvestmentTransactionCategory.dividend
}
if (
[
InvestmentTransactionSubtype.NonResidentTax,
InvestmentTransactionSubtype.Tax,
InvestmentTransactionSubtype.TaxWithheld,
].includes(subType)
) {
return InvestmentTransactionCategory.tax
}
if (
type === InvestmentTransactionType.Fee ||
[
InvestmentTransactionSubtype.AccountFee,
InvestmentTransactionSubtype.LegalFee,
InvestmentTransactionSubtype.ManagementFee,
InvestmentTransactionSubtype.MarginExpense,
InvestmentTransactionSubtype.TransferFee,
InvestmentTransactionSubtype.TrustFee,
].includes(subType)
) {
return InvestmentTransactionCategory.fee
}
if (type === InvestmentTransactionType.Cash) {
return InvestmentTransactionCategory.transfer
}
if (type === InvestmentTransactionType.Cancel) {
return InvestmentTransactionCategory.cancel
}
return InvestmentTransactionCategory.other
}
private async _extractHoldings(accessToken: string) { private async _extractHoldings(accessToken: string) {
try { try {
const { data } = await this.plaid.investmentsHoldingsGet({ access_token: accessToken }) const { data } = await this.plaid.investmentsHoldingsGet({ access_token: accessToken })

View file

@ -1,4 +1,5 @@
import type { AccountConnection, PrismaClient } from '@prisma/client' import type { AccountConnection, PrismaClient } from '@prisma/client'
import { AccountClassification } from '@prisma/client'
import type { Logger } from 'winston' import type { Logger } from 'winston'
import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared' import { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'
import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api' import type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'
@ -164,6 +165,12 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
private async _extractTransactions(accessToken: string, accountIds: string[]) { private async _extractTransactions(accessToken: string, accountIds: string[]) {
const accountTransactions = await Promise.all( const accountTransactions = await Promise.all(
accountIds.map(async (accountId) => { accountIds.map(async (accountId) => {
const account = await this.prisma.account.findFirst({
where: {
tellerAccountId: accountId,
},
})
const transactions = await SharedUtil.withRetry( const transactions = await SharedUtil.withRetry(
() => () =>
this.teller.getTransactions({ this.teller.getTransactions({
@ -174,6 +181,11 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
maxRetries: 3, maxRetries: 3,
} }
) )
if (account!.classification === AccountClassification.asset) {
transactions.forEach((t) => {
t.amount = String(Number(t.amount) * -1)
})
}
return transactions return transactions
}) })

View file

@ -1,10 +1,6 @@
import { import { Prisma, AccountCategory, AccountType } from '@prisma/client'
Prisma, import type { AccountClassification } from '@prisma/client'
AccountCategory, import type { Account } from '@prisma/client'
AccountType,
type Account,
type AccountClassification,
} from '@prisma/client'
import type { TellerTypes } from '@maybe-finance/teller-api' import type { TellerTypes } from '@maybe-finance/teller-api'
import { Duration } from 'luxon' import { Duration } from 'luxon'

View file

@ -0,0 +1,127 @@
-- AlterTable
ALTER TABLE "investment_transaction"
RENAME COLUMN "category" TO "category_old";
DROP VIEW IF EXISTS holdings_enriched;
ALTER TABLE "investment_transaction"
ADD COLUMN "category" "InvestmentTransactionCategory" NOT NULL DEFAULT 'other'::"InvestmentTransactionCategory";
CREATE OR REPLACE VIEW holdings_enriched AS (
SELECT
h.id,
h.account_id,
h.security_id,
h.quantity,
COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS "value",
COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS "cost_basis",
COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS "cost_basis_per_share",
pricing_latest.price_close AS "price",
pricing_prev.price_close AS "price_prev",
h.excluded
FROM
holding h
INNER JOIN security s ON s.id = h.security_id
-- latest security pricing
LEFT JOIN LATERAL (
SELECT
price_close
FROM
security_pricing
WHERE
security_id = h.security_id
ORDER BY
date DESC
LIMIT 1
) pricing_latest ON true
-- previous security pricing (for computing daily ∆)
LEFT JOIN LATERAL (
SELECT
price_close
FROM
security_pricing
WHERE
security_id = h.security_id
ORDER BY
date DESC
LIMIT 1
OFFSET 1
) pricing_prev ON true
-- calculate cost basis from transactions
LEFT JOIN (
SELECT
it.account_id,
it.security_id,
SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis
FROM
investment_transaction it
WHERE
it.category = 'buy'
AND it.quantity > 0
GROUP BY
it.account_id,
it.security_id
) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id
);
CREATE OR REPLACE FUNCTION calculate_return_dietz(p_account_id account.id%type, p_start date, p_end date, out percentage numeric, out amount numeric) AS $$
DECLARE
v_start date := GREATEST(p_start, (SELECT MIN(date) FROM account_balance WHERE account_id = p_account_id));
v_end date := p_end;
v_days int := v_end - v_start;
BEGIN
SELECT
ROUND((b1.balance - b0.balance - flows.net) / NULLIF(b0.balance + flows.weighted, 0), 4) AS "percentage",
b1.balance - b0.balance - flows.net AS "amount"
INTO
percentage, amount
FROM
account a
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(-fw.flow), 0) AS "net",
COALESCE(SUM(-fw.flow * fw.weight), 0) AS "weighted"
FROM (
SELECT
SUM(it.amount) AS flow,
(v_days - (it.date - v_start))::numeric / v_days AS weight
FROM
investment_transaction it
WHERE
it.account_id = a.id
AND it.date BETWEEN v_start AND v_end
-- filter for investment_transactions that represent external flows
AND it.category = 'transfer'
GROUP BY
it.date
) fw
) flows ON TRUE
LEFT JOIN LATERAL (
SELECT
ab.balance AS "balance"
FROM
account_balance ab
WHERE
ab.account_id = a.id AND ab.date <= v_start
ORDER BY
ab.date DESC
LIMIT 1
) b0 ON TRUE
LEFT JOIN LATERAL (
SELECT
COALESCE(ab.balance, a.current_balance) AS "balance"
FROM
account_balance ab
WHERE
ab.account_id = a.id AND ab.date <= v_end
ORDER BY
ab.date DESC
LIMIT 1
) b1 ON TRUE
WHERE
a.id = p_account_id;
END;
$$ LANGUAGE plpgsql STABLE;
ALTER TABLE "investment_transaction"
DROP COLUMN "category_old";

View file

@ -232,9 +232,7 @@ model InvestmentTransaction {
quantity Decimal @db.Decimal(36, 18) quantity Decimal @db.Decimal(36, 18)
price Decimal @db.Decimal(23, 8) price Decimal @db.Decimal(23, 8)
currencyCode String @default("USD") @map("currency_code") currencyCode String @default("USD") @map("currency_code")
category InvestmentTransactionCategory @default(other)
// Derived from provider types
category InvestmentTransactionCategory @default(dbgenerated("\nCASE\n WHEN (plaid_type = 'buy'::text) THEN 'buy'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'sell'::text) THEN 'sell'::\"InvestmentTransactionCategory\"\n WHEN (plaid_subtype = ANY (ARRAY['dividend'::text, 'qualified dividend'::text, 'non-qualified dividend'::text])) THEN 'dividend'::\"InvestmentTransactionCategory\"\n WHEN (plaid_subtype = ANY (ARRAY['non-resident tax'::text, 'tax'::text, 'tax withheld'::text])) THEN 'tax'::\"InvestmentTransactionCategory\"\n WHEN ((plaid_type = 'fee'::text) OR (plaid_subtype = ANY (ARRAY['account fee'::text, 'legal fee'::text, 'management fee'::text, 'margin expense'::text, 'transfer fee'::text, 'trust fee'::text]))) THEN 'fee'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'cash'::text) THEN 'transfer'::\"InvestmentTransactionCategory\"\n WHEN (plaid_type = 'cancel'::text) THEN 'cancel'::\"InvestmentTransactionCategory\"\n ELSE 'other'::\"InvestmentTransactionCategory\"\nEND"))
// plaid data // plaid data
plaidInvestmentTransactionId String? @unique @map("plaid_investment_transaction_id") plaidInvestmentTransactionId String? @unique @map("plaid_investment_transaction_id")