Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 1 addition & 1 deletion apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
52 changes: 30 additions & 22 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
btc: 65000,
eth: 3100,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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",
},
{
Expand All @@ -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",
},
{
Expand Down Expand Up @@ -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",
},
{
Expand Down
10 changes: 5 additions & 5 deletions apps/backend/src/controllers/swapController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}

Expand All @@ -223,22 +223,22 @@ 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." });
}

const settings = await prisma.swapSettings.upsert({
where: { walletId },
update: {
...(swapEnabled !== undefined ? { swapEnabled } : {}),
...(stableCoins !== undefined ? { stableCoins } : {}),
...(stableCoin !== undefined ? { stableCoin } : {}),
},
create: {
walletId,
swapEnabled: swapEnabled ?? false,
stableCoins: stableCoins ?? ["usdt", "usdc"],
stableCoin: stableCoin ?? "usdt",
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransactionRow[]>`
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") {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/models/SwapSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
2 changes: 1 addition & 1 deletion apps/backend/tests/helpers/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 3 additions & 9 deletions apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
13 changes: 11 additions & 2 deletions apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export function ChangeTransactionPopup({
if (!selectedWalletId || isLoading || !transaction)
return <div className="p-4 text-center">Loading...</div>;

const isSwap = !!transaction.swapGroupId;

const currentCoinInWallet = coinStats?.totalQuantity || 0;

const handleChange = (
Expand Down Expand Up @@ -164,7 +166,13 @@ export function ChangeTransactionPopup({
</div>
</div>

{alert && (
{isSwap && (
<div className="bg-sky-500/10 text-sky-300 border border-sky-400/30 p-3 rounded text-sm text-center">
Swap transactions cannot be edited individually.
</div>
)}

{!isSwap && alert && (
<div className="bg-red-100 text-red-700 p-2 rounded border border-red-200 text-sm text-center">
{alert}
</div>
Expand All @@ -174,7 +182,7 @@ export function ChangeTransactionPopup({
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
if (!isSwap) handleSubmit();
}}
>
<TransactionFormFields
Expand All @@ -184,6 +192,7 @@ export function ChangeTransactionPopup({
isLoading={isUpdating}
submitLabel="Update Transaction"
loadingLabel="Saving..."
disabled={isSwap}
/>
</form>
</div>
Expand Down
60 changes: 60 additions & 0 deletions apps/frontend/src/modules/Transactions/SwapDeleteConfirmPopup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-6 p-2">
<p className="text-sm text-center leading-relaxed">
This transaction is part of a swap. Deleting it will also remove the
linked transaction.{" "}
<span className="font-bold">Both will be permanently deleted.</span>
</p>

<div className="flex gap-3 justify-center">
<button
onClick={() => dispatch(closePopup())}
disabled={isLoading}
className="px-5 py-2 rounded border border-gray-400 text-sm disabled:opacity-50 cursor-pointer hover:bg-gray-500/20 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={isLoading}
className="px-5 py-2 rounded bg-red-600 text-white text-sm disabled:opacity-50 cursor-pointer hover:bg-red-700 transition-colors"
>
{isLoading ? "Deleting..." : "Confirm"}
</button>
</div>
</div>
);
}
Loading
Loading