diff --git a/apps/backend/prisma/migrations/20260524120000_normalize_stable_coin/migration.sql b/apps/backend/prisma/migrations/20260524120000_normalize_stable_coin/migration.sql new file mode 100644 index 0000000..2cd1e3b --- /dev/null +++ b/apps/backend/prisma/migrations/20260524120000_normalize_stable_coin/migration.sql @@ -0,0 +1,9 @@ +-- Replace stableCoins TEXT[] with a single scalar stableCoin TEXT to satisfy 1NF. +-- Existing rows keep the first element of the array (fallback to 'usdt' if NULL/empty). + +ALTER TABLE "SwapSettings" ADD COLUMN "stableCoin" TEXT NOT NULL DEFAULT 'usdt'; + +UPDATE "SwapSettings" +SET "stableCoin" = COALESCE("stableCoins"[1], 'usdt'); + +ALTER TABLE "SwapSettings" DROP COLUMN "stableCoins"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 318f8d2..9864978 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -105,6 +105,6 @@ model SwapSettings { id String @id @default(uuid()) walletId String @unique swapEnabled Boolean @default(false) - stableCoins String[] @default(["usdt", "usdc"]) + stableCoin String @default("usdt") wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) } diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index b648e44..fed15ce 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -627,7 +627,12 @@ async function main() { ], ]; - const coinCycle: ("btc" | "eth" | "sol" | "bnb")[] = ["btc", "eth", "sol", "bnb"]; + const coinCycle: ("btc" | "eth" | "sol" | "bnb")[] = [ + "btc", + "eth", + "sol", + "bnb", + ]; const basePrices: Record = { btc: 65000, eth: 3100, @@ -689,24 +694,24 @@ async function main() { // ===== SwapSettings (one per wallet, hand-crafted) ===== // walletId is @unique on SwapSettings, so one row per chosen wallet. const swapSettings = [ - { walletId: wallets[0].id, swapEnabled: true, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[1].id, swapEnabled: false, stableCoins: ["usdt"] }, - { walletId: wallets[2].id, swapEnabled: true, stableCoins: ["usdt", "usdc", "dai"] }, - { walletId: wallets[3].id, swapEnabled: true, stableCoins: ["usdc"] }, - { walletId: wallets[4].id, swapEnabled: false, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[5].id, swapEnabled: true, stableCoins: ["usdt"] }, - { walletId: wallets[6].id, swapEnabled: true, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[7].id, swapEnabled: false, stableCoins: ["usdc"] }, - { walletId: wallets[8].id, swapEnabled: true, stableCoins: ["usdt", "dai"] }, - { walletId: wallets[9].id, swapEnabled: true, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[10].id, swapEnabled: false, stableCoins: ["usdt"] }, - { walletId: wallets[11].id, swapEnabled: true, stableCoins: ["usdc", "dai"] }, - { walletId: wallets[12].id, swapEnabled: true, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[13].id, swapEnabled: true, stableCoins: ["usdt"] }, - { walletId: wallets[14].id, swapEnabled: false, stableCoins: ["usdt", "usdc"] }, - { walletId: wallets[15].id, swapEnabled: true, stableCoins: ["usdt", "usdc", "dai"] }, - { walletId: wallets[16].id, swapEnabled: true, stableCoins: ["usdc"] }, - { walletId: wallets[17].id, swapEnabled: false, stableCoins: ["usdt", "usdc"] }, + { walletId: wallets[0].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[1].id, swapEnabled: false, stableCoin: "usdt" }, + { walletId: wallets[2].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[3].id, swapEnabled: true, stableCoin: "usdc" }, + { walletId: wallets[4].id, swapEnabled: false, stableCoin: "usdt" }, + { walletId: wallets[5].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[6].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[7].id, swapEnabled: false, stableCoin: "usdc" }, + { walletId: wallets[8].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[9].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[10].id, swapEnabled: false, stableCoin: "usdt" }, + { walletId: wallets[11].id, swapEnabled: true, stableCoin: "usdc" }, + { walletId: wallets[12].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[13].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[14].id, swapEnabled: false, stableCoin: "usdt" }, + { walletId: wallets[15].id, swapEnabled: true, stableCoin: "usdt" }, + { walletId: wallets[16].id, swapEnabled: true, stableCoin: "usdc" }, + { walletId: wallets[17].id, swapEnabled: false, stableCoin: "usdt" }, ].map((row) => ({ id: randomUUID(), ...row })); await prisma.swapSettings.createMany({ data: swapSettings }); @@ -771,7 +776,8 @@ async function main() { createdAt: dateDaysAgo(4), revokedAt: null, replacedByTokenHash: null, - userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0", ip: "82.118.20.5", }, { @@ -781,7 +787,8 @@ async function main() { createdAt: dateDaysAgo(35), revokedAt: null, replacedByTokenHash: null, - userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/123.0.0.0", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/123.0.0.0", ip: "82.118.20.5", }, { @@ -821,7 +828,8 @@ async function main() { createdAt: dateDaysAgo(2), revokedAt: null, replacedByTokenHash: null, - userAgent: "Mozilla/5.0 (Windows NT 10.0; rv:126.0) Gecko/20100101 Firefox/126.0", + userAgent: + "Mozilla/5.0 (Windows NT 10.0; rv:126.0) Gecko/20100101 Firefox/126.0", ip: "176.36.10.55", }, { diff --git a/apps/backend/src/controllers/swapController.ts b/apps/backend/src/controllers/swapController.ts index 5822b47..530b6e1 100644 --- a/apps/backend/src/controllers/swapController.ts +++ b/apps/backend/src/controllers/swapController.ts @@ -199,7 +199,7 @@ export const getSwapSettings = async (req: Request, res: Response) => { return res.status(200).json({ walletId, swapEnabled: false, - stableCoins: ["usdt", "usdc"], + stableCoin: "usdt", }); } @@ -223,9 +223,9 @@ export const updateSwapSettings = async (req: Request, res: Response) => { return handleZodError(res, validationResult.error); } - const { swapEnabled, stableCoins } = validationResult.data; + const { swapEnabled, stableCoin } = validationResult.data; - if (swapEnabled === undefined && stableCoins === undefined) { + if (swapEnabled === undefined && stableCoin === undefined) { return res.status(400).json({ error: "No fields provided for update." }); } @@ -233,12 +233,12 @@ export const updateSwapSettings = async (req: Request, res: Response) => { where: { walletId }, update: { ...(swapEnabled !== undefined ? { swapEnabled } : {}), - ...(stableCoins !== undefined ? { stableCoins } : {}), + ...(stableCoin !== undefined ? { stableCoin } : {}), }, create: { walletId, swapEnabled: swapEnabled ?? false, - stableCoins: stableCoins ?? ["usdt", "usdc"], + stableCoin: stableCoin ?? "usdt", }, }); diff --git a/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts b/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts index 1e2b601..377eee6 100644 --- a/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts +++ b/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts @@ -303,6 +303,54 @@ export const deleteTransaction = async (req: Request, res: Response) => { if (!transactionToDelete) return res.status(404).json({ error: "Transaction not found" }); + // Swap: cascade-delete both legs atomically + if (transactionToDelete.swapGroupId !== null) { + const swapGroupId = transactionToDelete.swapGroupId; + + const swapGroup = await prisma.$queryRaw` + SELECT + "id", "walletId", "coinSymbol", "swapGroupId", + "buyOrSell", "price", "quantity", "createdAt", "updatedAt" + FROM "Transaction" + WHERE "swapGroupId" = ${swapGroupId}; + `; + + // Validate balance only for the buy leg + for (const swapTx of swapGroup) { + if (swapTx.buyOrSell !== "buy") continue; + + const remaining = await prisma.$queryRaw< + { buyOrSell: "buy" | "sell"; quantity: number | string }[] + >` + SELECT "buyOrSell", "quantity" + FROM "Transaction" + WHERE + "walletId" = ${walletId} + AND "coinSymbol" = ${swapTx.coinSymbol} + AND "swapGroupId" IS DISTINCT FROM ${swapGroupId} + ORDER BY "createdAt" ASC, "id" ASC; + `; + + let runningBalance = 0; + for (const tx of remaining) { + const qty = Number(tx.quantity); + runningBalance += tx.buyOrSell === "buy" ? qty : -qty; + if (runningBalance < 0) { + return res.status(400).json({ + error: `Cannot delete swap. This would break chronological balance for ${swapTx.coinSymbol.toUpperCase()}.`, + }); + } + } + } + + await prisma.$executeRaw` + DELETE FROM "Transaction" + WHERE "swapGroupId" = ${swapGroupId}; + `; + + return res.status(200).json({ message: "Swap deleted", swapGroupId }); + } + const symbol = transactionToDelete.coinSymbol; if (transactionToDelete.buyOrSell === "buy") { @@ -408,6 +456,13 @@ export const updateTransaction = async (req: Request, res: Response) => { if (!oldTransaction) return res.status(404).json({ error: "Transaction not found" }); + if (oldTransaction.swapGroupId !== null) { + return res.status(403).json({ + error: + "Swap transactions cannot be edited individually. Delete the swap to remove both legs.", + }); + } + const validationResult = CreateTransactionDto.omit({ walletId: true, coinSymbol: true, diff --git a/apps/backend/src/models/SwapSchema.ts b/apps/backend/src/models/SwapSchema.ts index ddf6f4a..86cfb27 100644 --- a/apps/backend/src/models/SwapSchema.ts +++ b/apps/backend/src/models/SwapSchema.ts @@ -12,5 +12,5 @@ export const CreateSwapSchema = z.object({ export const UpdateSwapSettingsSchema = z.object({ swapEnabled: z.boolean().optional(), - stableCoins: z.array(z.string().trim().toLowerCase().min(1)).optional(), + stableCoin: z.string().trim().toLowerCase().min(1).optional(), }); diff --git a/apps/backend/tests/helpers/testUtils.ts b/apps/backend/tests/helpers/testUtils.ts index 0bf3788..5076617 100644 --- a/apps/backend/tests/helpers/testUtils.ts +++ b/apps/backend/tests/helpers/testUtils.ts @@ -70,7 +70,7 @@ export const enableSwap = async (agent: TestAgent, walletId: string) => { .patch(`/api/wallets/${walletId}/swap-settings`) .send({ swapEnabled: true, - stableCoins: ["usdt", "usdc"], + stableCoin: "usdt", }); if (response.status !== 200) { diff --git a/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx b/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx index 78a7b67..6e82fd9 100644 --- a/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx +++ b/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx @@ -49,9 +49,7 @@ export function AddTransactionPopup({ coin }: { coin: Coin }) { const currentCoinInWallet = coinStats?.totalQuantity || 0; const averageBuyingPrice = coinStats?.avgBuyingPrice || 0; const swapEnabled = swapSettings?.swapEnabled ?? false; - const activeStableCoin = ( - swapSettings?.stableCoins?.[0] || "usdt" - ).toUpperCase(); + const activeStableCoin = (swapSettings?.stableCoin || "usdt").toUpperCase(); const isLoading = isCreateTransactionLoading || isCreateSwapLoading; const handleChange = ( @@ -93,9 +91,7 @@ export function AddTransactionPopup({ coin }: { coin: Coin }) { try { if (form.buyOrSell === "buy" && swapEnabled && payWithSwap) { - const stableCoin = ( - swapSettings?.stableCoins?.[0] || "usdt" - ).toLowerCase(); + const stableCoin = (swapSettings?.stableCoin || "usdt").toLowerCase(); const totalToSpend = Number( form.total_price || (Number(form.quantity) * Number(form.price)).toString(), @@ -114,9 +110,7 @@ export function AddTransactionPopup({ coin }: { coin: Coin }) { }, }).unwrap(); } else if (form.buyOrSell === "sell" && swapEnabled && payWithSwap) { - const stableCoin = ( - swapSettings?.stableCoins?.[0] || "usdt" - ).toLowerCase(); + const stableCoin = (swapSettings?.stableCoin || "usdt").toLowerCase(); const stableQuantity = Number( form.total_price || (Number(form.quantity) * Number(form.price)).toString(), diff --git a/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx b/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx index 7d32739..94e9597 100644 --- a/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx +++ b/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx @@ -69,6 +69,8 @@ export function ChangeTransactionPopup({ if (!selectedWalletId || isLoading || !transaction) return
Loading...
; + const isSwap = !!transaction.swapGroupId; + const currentCoinInWallet = coinStats?.totalQuantity || 0; const handleChange = ( @@ -164,7 +166,13 @@ export function ChangeTransactionPopup({ - {alert && ( + {isSwap && ( +
+ Swap transactions cannot be edited individually. +
+ )} + + {!isSwap && alert && (
{alert}
@@ -174,7 +182,7 @@ export function ChangeTransactionPopup({ className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); - handleSubmit(); + if (!isSwap) handleSubmit(); }} > diff --git a/apps/frontend/src/modules/Transactions/SwapDeleteConfirmPopup.tsx b/apps/frontend/src/modules/Transactions/SwapDeleteConfirmPopup.tsx new file mode 100644 index 0000000..8b226bb --- /dev/null +++ b/apps/frontend/src/modules/Transactions/SwapDeleteConfirmPopup.tsx @@ -0,0 +1,60 @@ +import { useAppDispatch } from "../../store"; +import { closePopup, openPopup } from "../../portals/popup.slice"; +import { useDeleteTransactionMutation } from "./transaction.api"; +import { extractApiErrorMessage } from "../../utils/functions"; + +export function SwapDeleteConfirmPopup({ + walletId, + transactionId, +}: { + walletId: string; + transactionId: string; +}) { + const dispatch = useAppDispatch(); + const [deleteTransaction, { isLoading }] = useDeleteTransactionMutation(); + + const handleConfirm = async () => { + try { + await deleteTransaction({ walletId, transactionId }).unwrap(); + dispatch(closePopup()); + setTimeout(() => { + dispatch( + openPopup({ title: "Success", children: "Transaction deleted!" }), + ); + }, 300); + } catch (error: unknown) { + dispatch(closePopup()); + const message = extractApiErrorMessage(error); + setTimeout(() => { + dispatch(openPopup({ title: "Failure", children: message })); + }, 300); + } + }; + + return ( +
+

+ This transaction is part of a swap. Deleting it will also remove the + linked transaction.{" "} + Both will be permanently deleted. +

+ +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/modules/Transactions/SwapSettingsPopup.tsx b/apps/frontend/src/modules/Transactions/SwapSettingsPopup.tsx index 0d4f1fe..bd12939 100644 --- a/apps/frontend/src/modules/Transactions/SwapSettingsPopup.tsx +++ b/apps/frontend/src/modules/Transactions/SwapSettingsPopup.tsx @@ -4,6 +4,8 @@ import { useUpdateSwapSettingsMutation, } from "./swap.api"; +const AVAILABLE_STABLE_COINS = ["usdt", "usdc"] as const; + export function SwapSettingsPopup() { const selectedWalletId = useAppSelector( (state) => state.selectedWallet.selectedWalletId, @@ -19,8 +21,7 @@ export function SwapSettingsPopup() { if (!selectedWalletId) return null; const swapEnabled = settings?.swapEnabled ?? false; - const stableCoins = settings?.stableCoins ?? ["usdt", "usdc"]; - const activeStableCoin = stableCoins[0] || "usdt"; + const activeStableCoin = settings?.stableCoin ?? "usdt"; const handleToggle = async () => { await updateSwapSettings({ @@ -30,14 +31,9 @@ export function SwapSettingsPopup() { }; const handleSelectStableCoin = async (selectedCoin: string) => { - const reordered = [ - selectedCoin, - ...stableCoins.filter((coin) => coin !== selectedCoin), - ]; - await updateSwapSettings({ walletId: selectedWalletId, - data: { stableCoins: reordered }, + data: { stableCoin: selectedCoin }, }); }; @@ -60,7 +56,7 @@ export function SwapSettingsPopup() {
- {stableCoins.map((coin) => { + {AVAILABLE_STABLE_COINS.map((coin) => { const isActive = coin === activeStableCoin; return ( Total Date {isLoading ? loadingLabel : submitLabel} diff --git a/apps/frontend/src/modules/Transactions/TransactionRow.tsx b/apps/frontend/src/modules/Transactions/TransactionRow.tsx index 57e9b11..fd24d92 100644 --- a/apps/frontend/src/modules/Transactions/TransactionRow.tsx +++ b/apps/frontend/src/modules/Transactions/TransactionRow.tsx @@ -56,12 +56,16 @@ export function TransactionRow({ {new Date(transaction.createdAt).toLocaleString()}
- + {transaction.swapGroupId ? ( +
+ ) : ( + + )}