feat: shop activation + DB commission wallets + onion display (deploy pipeline)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
- src/services/commissionService.js: read commission wallets and shop_activated from site_settings (DB) with env fallback, 30s cache - src/handlers/adminHandlers/adminWalletsHandler.js: commission wallets from CommissionService instead of static config - src/handlers/userHandlers/userHandler.js: canUseBot blocks all users when shop not activated - src/migrations/015_shop_activation.js: seed shop_activated + commission_wallet_* into site_settings; register in runner - admin-next: /api/system/info (onion hosts), /api/commission-wallets GET/PUT (super admin), /api/activation GET/PUT (super admin); lib/commission-wallets.ts shared helper; onion badge in header; shop activation card in settings; wallet editor in wallets page - test: commissionService.test.js (10 cases: DB/env fallback, caching)
This commit is contained in:
@@ -22,8 +22,9 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Moon, Sun, LogOut, User, Search } from "lucide-react";
|
import { Moon, Sun, LogOut, User, Search, Globe, Copy } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
|
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
|
||||||
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
|
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
|
||||||
@@ -79,6 +80,7 @@ export function AdminHeader() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { role, logout } = useAuthStore();
|
const { role, logout } = useAuthStore();
|
||||||
const [logoutOpen, setLogoutOpen] = useState(false);
|
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||||
|
const [onion, setOnion] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setHash(window.location.hash.slice(1) || "/");
|
const update = () => setHash(window.location.hash.slice(1) || "/");
|
||||||
@@ -87,6 +89,35 @@ export function AdminHeader() {
|
|||||||
return () => window.removeEventListener("hashchange", update);
|
return () => window.removeEventListener("hashchange", update);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/system/info");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch system info");
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
setOnion(data.adminOnion || "");
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setOnion("");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyOnion = async () => {
|
||||||
|
if (!onion) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(onion);
|
||||||
|
toast.success("Onion address copied");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to copy onion address");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
pageTitles[hash] ||
|
pageTitles[hash] ||
|
||||||
(hash.startsWith("/users/")
|
(hash.startsWith("/users/")
|
||||||
@@ -105,6 +136,18 @@ export function AdminHeader() {
|
|||||||
</h1>
|
</h1>
|
||||||
{/* 1. Clock (hidden on mobile) */}
|
{/* 1. Clock (hidden on mobile) */}
|
||||||
<RealtimeClock />
|
<RealtimeClock />
|
||||||
|
{/* 1b. Onion host badge (hidden on mobile) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyOnion}
|
||||||
|
disabled={!onion}
|
||||||
|
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
||||||
|
className="hidden md:flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-muted transition-colors shrink-0 max-w-[220px]"
|
||||||
|
>
|
||||||
|
<Globe className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{onion || "onion: —"}</span>
|
||||||
|
{onion && <Copy className="size-3 shrink-0 opacity-60" />}
|
||||||
|
</button>
|
||||||
{/* 2. Command palette search button */}
|
{/* 2. Command palette search button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff } from "lucide-react";
|
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
|
||||||
|
|
||||||
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
||||||
|
|
||||||
@@ -88,6 +89,83 @@ export function SettingsPage() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||||
const [revealing, setRevealing] = useState<string | null>(null);
|
const [revealing, setRevealing] = useState<string | null>(null);
|
||||||
|
const [activated, setActivated] = useState<boolean | null>(null);
|
||||||
|
const [activationLoading, setActivationLoading] = useState(false);
|
||||||
|
const [activationDialogOpen, setActivationDialogOpen] = useState(false);
|
||||||
|
const [onion, setOnion] = useState("");
|
||||||
|
const [lanUrl, setLanUrl] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/activation");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch activation");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!cancelled) setActivated(!!data.activated);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setActivated(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/system/info");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch system info");
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
setOnion(data.adminOnion || "");
|
||||||
|
setLanUrl(data.lanUrl || "");
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setOnion("");
|
||||||
|
setLanUrl("");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
const copyText = async (text: string, label: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${label} copied`);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to copy ${label}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActivation = async () => {
|
||||||
|
if (activated === null) return;
|
||||||
|
const next = !activated;
|
||||||
|
setActivationLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/activation", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ activated: next }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update activation");
|
||||||
|
const data = await res.json();
|
||||||
|
setActivated(!!data.activated);
|
||||||
|
toast.success(next ? "Shop activated" : "Shop blocked");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update shop activation");
|
||||||
|
} finally {
|
||||||
|
setActivationLoading(false);
|
||||||
|
setActivationDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
fetch("/api/settings")
|
||||||
@@ -431,6 +509,110 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Shop Activation (super admin only) */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||||
|
Shop Activation
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control whether the shop is active and accepting orders. Blocking the shop
|
||||||
|
immediately disables purchasing for all users.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{activated === null ? (
|
||||||
|
<Badge variant="secondary">Checking...</Badge>
|
||||||
|
) : activated ? (
|
||||||
|
<Badge className="bg-emerald-500/15 text-emerald-400">Activated</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/15 text-red-400">Blocked</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{activated === null
|
||||||
|
? "Loading activation status..."
|
||||||
|
: activated
|
||||||
|
? "The shop is currently active and accepting orders."
|
||||||
|
: "The shop is currently blocked. Users cannot place orders."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Onion:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyText(onion, "Onion address")}
|
||||||
|
disabled={!onion}
|
||||||
|
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{onion || "—"}
|
||||||
|
{onion && <Copy className="h-3 w-3 opacity-60" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">LAN:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyText(lanUrl, "LAN URL")}
|
||||||
|
disabled={!lanUrl}
|
||||||
|
title={lanUrl ? "Copy LAN URL" : "LAN URL not ready yet"}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{lanUrl || "—"}
|
||||||
|
{lanUrl && <Copy className="h-3 w-3 opacity-60" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={activationDialogOpen} onOpenChange={setActivationDialogOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={activated ? "destructive" : "default"}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={activated === null || activationLoading}
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
{activated ? "Block Shop" : "Activate Shop"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activated ? "Block Shop" : "Activate Shop"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activated
|
||||||
|
? "Blocking the shop will immediately prevent all users from placing orders. This action can be reversed at any time."
|
||||||
|
: "Activating the shop will allow users to place orders again. This action can be reversed at any time."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleToggleActivation}
|
||||||
|
disabled={activationLoading}
|
||||||
|
className={activated ? "bg-destructive text-white hover:bg-destructive/90" : ""}
|
||||||
|
>
|
||||||
|
{activationLoading
|
||||||
|
? "Updating..."
|
||||||
|
: activated
|
||||||
|
? "Block Shop"
|
||||||
|
: "Activate Shop"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
admin-next/src/lib/commission-wallets.ts
Normal file
27
admin-next/src/lib/commission-wallets.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const;
|
||||||
|
|
||||||
|
const ENV_KEYS: Record<string, string> = {
|
||||||
|
BTC: 'COMMISSION_WALLET_BTC',
|
||||||
|
LTC: 'COMMISSION_WALLET_LTC',
|
||||||
|
USDT: 'COMMISSION_WALLET_USDT',
|
||||||
|
USDC: 'COMMISSION_WALLET_USDC',
|
||||||
|
ETH: 'COMMISSION_WALLET_ETH',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCommissionWallets(): Promise<Record<string, string>> {
|
||||||
|
const keys = WALLET_TYPES.map((t) => `commission_wallet_${t}`);
|
||||||
|
const rows = await db.siteSetting.findMany({
|
||||||
|
where: { key: { in: keys } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wallets: Record<string, string> = {};
|
||||||
|
for (const type of WALLET_TYPES) {
|
||||||
|
const dbKey = `commission_wallet_${type}`;
|
||||||
|
const row = rows.find((r) => r.key === dbKey);
|
||||||
|
wallets[type] = row?.value || process.env[ENV_KEYS[type]] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return wallets;
|
||||||
|
}
|
||||||
240
src/__tests__/commissionService.test.js
Normal file
240
src/__tests__/commissionService.test.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('../config/config.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
BOT_TOKEN: 'test-token',
|
||||||
|
ADMIN_IDS: ['123456789'],
|
||||||
|
SUPER_ADMIN_IDS: ['123456789'],
|
||||||
|
SUPPORT_LINK: 'https://t.me/support',
|
||||||
|
DEFAULT_LANGUAGE: 'en',
|
||||||
|
ENCRYPTION_KEY: 'x'.repeat(64),
|
||||||
|
COMMISSION_ENABLED: true,
|
||||||
|
COMMISSION_PERCENT: 5,
|
||||||
|
COMMISSION_WALLETS: {
|
||||||
|
BTC: 'btc-env-address',
|
||||||
|
LTC: 'ltc-env-address',
|
||||||
|
USDT: 'usdt-env-address',
|
||||||
|
USDC: 'usdc-env-address',
|
||||||
|
ETH: 'eth-env-address'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('../utils/logger.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
fatal: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Track DB calls
|
||||||
|
const dbCalls = {
|
||||||
|
allAsync: [],
|
||||||
|
getAsync: [],
|
||||||
|
runAsync: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock database
|
||||||
|
vi.mock('../config/database.js', () => {
|
||||||
|
const db = {
|
||||||
|
allAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
dbCalls.allAsync.push({ sql, params });
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
getAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
dbCalls.getAsync.push({ sql, params });
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
runAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
dbCalls.runAsync.push({ sql, params });
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return { __esModule: true, default: db };
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need to import after mocks are set up
|
||||||
|
let CommissionService;
|
||||||
|
|
||||||
|
describe('CommissionService', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
dbCalls.allAsync = [];
|
||||||
|
dbCalls.getAsync = [];
|
||||||
|
dbCalls.runAsync = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Dynamic import to get fresh module with mocks
|
||||||
|
CommissionService = (await import('../services/commissionService.js')).default;
|
||||||
|
|
||||||
|
// Invalidate cache before each test
|
||||||
|
CommissionService.invalidateCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCommissionWallets', () => {
|
||||||
|
it('returns wallets from DB when all are set', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
|
||||||
|
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
|
||||||
|
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wallets = await CommissionService.getCommissionWallets();
|
||||||
|
|
||||||
|
expect(wallets).toEqual({
|
||||||
|
BTC: 'btc-db-address',
|
||||||
|
LTC: 'ltc-db-address',
|
||||||
|
USDT: 'usdt-db-address',
|
||||||
|
USDC: 'usdc-db-address',
|
||||||
|
ETH: 'eth-db-address'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to env when DB value is empty string', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: '' },
|
||||||
|
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDT', value: '' },
|
||||||
|
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
|
||||||
|
{ key: 'commission_wallet_ETH', value: '' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wallets = await CommissionService.getCommissionWallets();
|
||||||
|
|
||||||
|
expect(wallets.BTC).toBe('btc-env-address');
|
||||||
|
expect(wallets.LTC).toBe('ltc-db-address');
|
||||||
|
expect(wallets.USDT).toBe('usdt-env-address');
|
||||||
|
expect(wallets.USDC).toBe('usdc-db-address');
|
||||||
|
expect(wallets.ETH).toBe('eth-env-address');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to env when DB key is missing', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: 'btc-db-address' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wallets = await CommissionService.getCommissionWallets();
|
||||||
|
|
||||||
|
expect(wallets.BTC).toBe('btc-db-address');
|
||||||
|
expect(wallets.LTC).toBe('ltc-env-address');
|
||||||
|
expect(wallets.USDT).toBe('usdt-env-address');
|
||||||
|
expect(wallets.USDC).toBe('usdc-env-address');
|
||||||
|
expect(wallets.ETH).toBe('eth-env-address');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches result: second call does not hit DB', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
|
||||||
|
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
|
||||||
|
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await CommissionService.getCommissionWallets();
|
||||||
|
const callCount = dbCalls.allAsync.length;
|
||||||
|
|
||||||
|
await CommissionService.getCommissionWallets();
|
||||||
|
expect(dbCalls.allAsync.length).toBe(callCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShopActivated', () => {
|
||||||
|
it('returns false when DB has "false"', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'false' });
|
||||||
|
|
||||||
|
const result = await CommissionService.getShopActivated();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when DB has "true"', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' });
|
||||||
|
|
||||||
|
const result = await CommissionService.getShopActivated();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when DB key missing and env SHOP_ACTIVATED is "false"', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.getAsync.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const original = process.env.SHOP_ACTIVATED;
|
||||||
|
process.env.SHOP_ACTIVATED = 'false';
|
||||||
|
try {
|
||||||
|
const result = await CommissionService.getShopActivated();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
} finally {
|
||||||
|
process.env.SHOP_ACTIVATED = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when DB key missing and env SHOP_ACTIVATED is not set', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.getAsync.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const original = process.env.SHOP_ACTIVATED;
|
||||||
|
delete process.env.SHOP_ACTIVATED;
|
||||||
|
try {
|
||||||
|
const result = await CommissionService.getShopActivated();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (original !== undefined) {
|
||||||
|
process.env.SHOP_ACTIVATED = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches result: second call does not hit DB', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' });
|
||||||
|
|
||||||
|
await CommissionService.getShopActivated();
|
||||||
|
const callCount = dbCalls.getAsync.length;
|
||||||
|
|
||||||
|
await CommissionService.getShopActivated();
|
||||||
|
expect(dbCalls.getAsync.length).toBe(callCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidateCache', () => {
|
||||||
|
it('forces re-fetch after invalidation', async () => {
|
||||||
|
const { default: dbMock } = await import('../config/database.js');
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
|
||||||
|
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
|
||||||
|
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
|
||||||
|
]);
|
||||||
|
dbMock.allAsync.mockResolvedValueOnce([
|
||||||
|
{ key: 'commission_wallet_BTC', value: 'btc-db-address-v2' },
|
||||||
|
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
|
||||||
|
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
|
||||||
|
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const first = await CommissionService.getCommissionWallets();
|
||||||
|
expect(first.BTC).toBe('btc-db-address');
|
||||||
|
|
||||||
|
CommissionService.invalidateCache();
|
||||||
|
|
||||||
|
const second = await CommissionService.getCommissionWallets();
|
||||||
|
expect(second.BTC).toBe('btc-db-address-v2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,20 +14,11 @@ import WalletUtils from '../../utils/walletUtils.js';
|
|||||||
import Validators from '../../utils/validators.js';
|
import Validators from '../../utils/validators.js';
|
||||||
import logger from '../../utils/logger.js';
|
import logger from '../../utils/logger.js';
|
||||||
import { logAudit } from '../../services/auditService.js';
|
import { logAudit } from '../../services/auditService.js';
|
||||||
|
import CommissionService from '../../services/commissionService.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import csvWriter from 'csv-writer';
|
import csvWriter from 'csv-writer';
|
||||||
|
|
||||||
export default class AdminWalletsHandler {
|
export default class AdminWalletsHandler {
|
||||||
static {
|
|
||||||
if (config.COMMISSION_ENABLED) {
|
|
||||||
const requiredWallets = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
|
|
||||||
const missingWallets = requiredWallets.filter(wallet => !config.COMMISSION_WALLETS[wallet]);
|
|
||||||
|
|
||||||
if (missingWallets.length > 0) {
|
|
||||||
logger.warn({ missingWallets }, `Commission enabled but wallet addresses missing for: ${missingWallets.join(', ')}. Commission features will be limited.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Метод для проверки, является ли пользователь администратором
|
// Метод для проверки, является ли пользователь администратором
|
||||||
// (используется общая функция из middleware/auth.js)
|
// (используется общая функция из middleware/auth.js)
|
||||||
@@ -218,7 +209,8 @@ export default class AdminWalletsHandler {
|
|||||||
try {
|
try {
|
||||||
logger.info({ walletType, requiredAmount: requiredAmount.toFixed(8) }, 'Checking commission balance');
|
logger.info({ walletType, requiredAmount: requiredAmount.toFixed(8) }, 'Checking commission balance');
|
||||||
|
|
||||||
const commissionWallet = config.COMMISSION_WALLETS[walletType];
|
const wallets = await CommissionService.getCommissionWallets();
|
||||||
|
const commissionWallet = wallets[walletType];
|
||||||
if (!commissionWallet) {
|
if (!commissionWallet) {
|
||||||
throw new Error(`Commission wallet not configured for ${walletType}`);
|
throw new Error(`Commission wallet not configured for ${walletType}`);
|
||||||
}
|
}
|
||||||
@@ -396,8 +388,9 @@ export default class AdminWalletsHandler {
|
|||||||
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
|
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
|
||||||
|
|
||||||
if (commissionCheck.difference < 0) {
|
if (commissionCheck.difference < 0) {
|
||||||
|
const commissionWallets = await CommissionService.getCommissionWallets();
|
||||||
const message = `⚠️ Insufficient balance in commission wallet!\n` +
|
const message = `⚠️ Insufficient balance in commission wallet!\n` +
|
||||||
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
|
`Wallet: ${commissionWallets[walletType]}\n` +
|
||||||
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
|
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
|
||||||
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
|
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
|
||||||
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
|
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
|
||||||
@@ -534,8 +527,9 @@ export default class AdminWalletsHandler {
|
|||||||
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
|
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
|
||||||
|
|
||||||
if (commissionCheck.difference < 0) {
|
if (commissionCheck.difference < 0) {
|
||||||
|
const commissionWallets = await CommissionService.getCommissionWallets();
|
||||||
const message = `⚠️ Insufficient balance in commission wallet!\n` +
|
const message = `⚠️ Insufficient balance in commission wallet!\n` +
|
||||||
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
|
`Wallet: ${commissionWallets[walletType]}\n` +
|
||||||
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
|
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
|
||||||
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
|
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
|
||||||
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
|
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logger from "../../utils/logger.js";
|
|||||||
import { resetUserContext } from "../../utils/messageUtils.js";
|
import { resetUserContext } from "../../utils/messageUtils.js";
|
||||||
import userStates from "../../context/userStates.js";
|
import userStates from "../../context/userStates.js";
|
||||||
import chatbotService from "../../services/chatbotService.js";
|
import chatbotService from "../../services/chatbotService.js";
|
||||||
|
import CommissionService from "../../services/commissionService.js";
|
||||||
import leadService from "../../services/leadService.js";
|
import leadService from "../../services/leadService.js";
|
||||||
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ export default class UserHandler {
|
|||||||
const lang = user?.language || 'en';
|
const lang = user?.language || 'en';
|
||||||
const t = tForUser(lang);
|
const t = tForUser(lang);
|
||||||
|
|
||||||
|
if (!(await CommissionService.getShopActivated())) {
|
||||||
|
await bot.sendMessage(telegramId, '⛔ Shop is not activated yet. Please contact support.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const keyboard = {
|
const keyboard = {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[{text: t('bot.contact_support'), url: config.SUPPORT_LINK}]
|
[{text: t('bot.contact_support'), url: config.SUPPORT_LINK}]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export async function runMigrations() {
|
|||||||
(await import('./012_fix_typos.js')).default,
|
(await import('./012_fix_typos.js')).default,
|
||||||
(await import('./013_ai_features.js')).default,
|
(await import('./013_ai_features.js')).default,
|
||||||
(await import('./014_user_notes.js')).default,
|
(await import('./014_user_notes.js')).default,
|
||||||
|
(await import('./015_shop_activation.js')).default,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = currentVersion; i < migrations.length; i++) {
|
for (let i = currentVersion; i < migrations.length; i++) {
|
||||||
|
|||||||
102
src/services/commissionService.js
Normal file
102
src/services/commissionService.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import db from '../config/database.js';
|
||||||
|
import config from '../config/config.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30 * 1000; // 30 sec
|
||||||
|
|
||||||
|
let commissionCache = null;
|
||||||
|
let commissionCachedAt = 0;
|
||||||
|
|
||||||
|
let shopActivatedCache = null;
|
||||||
|
let shopActivatedCachedAt = 0;
|
||||||
|
|
||||||
|
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
|
||||||
|
|
||||||
|
function isCacheValid(cachedAt) {
|
||||||
|
return Date.now() - cachedAt < CACHE_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commission wallet addresses from site_settings with env fallback.
|
||||||
|
* @returns {Promise<{BTC: string, LTC: string, USDT: string, USDC: string, ETH: string}>}
|
||||||
|
*/
|
||||||
|
export async function getCommissionWallets() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (commissionCache && isCacheValid(commissionCachedAt)) {
|
||||||
|
return commissionCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await db.allAsync(
|
||||||
|
`SELECT key, value FROM site_settings WHERE key IN (${WALLET_TYPES.map(() => '?').join(',')})`,
|
||||||
|
WALLET_TYPES.map(t => `commission_wallet_${t}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbValues = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const type = row.key.replace('commission_wallet_', '');
|
||||||
|
dbValues[type] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallets = {};
|
||||||
|
for (const type of WALLET_TYPES) {
|
||||||
|
const dbVal = dbValues[type];
|
||||||
|
wallets[type] = (dbVal && dbVal !== '') ? dbVal : (config.COMMISSION_WALLETS[type] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
commissionCache = wallets;
|
||||||
|
commissionCachedAt = now;
|
||||||
|
return wallets;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to load commission wallets from site_settings');
|
||||||
|
return { ...config.COMMISSION_WALLETS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the shop is activated.
|
||||||
|
* Reads site_settings.shop_activated; falls back to env SHOP_ACTIVATED !== 'false' (default true).
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function getShopActivated() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (shopActivatedCache !== null && isCacheValid(shopActivatedCachedAt)) {
|
||||||
|
return shopActivatedCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await db.getAsync(
|
||||||
|
"SELECT value FROM site_settings WHERE key = 'shop_activated'"
|
||||||
|
);
|
||||||
|
|
||||||
|
let activated;
|
||||||
|
if (row) {
|
||||||
|
activated = row.value !== 'false';
|
||||||
|
} else {
|
||||||
|
activated = process.env.SHOP_ACTIVATED !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
shopActivatedCache = activated;
|
||||||
|
shopActivatedCachedAt = now;
|
||||||
|
return activated;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Failed to read shop_activated from site_settings');
|
||||||
|
return process.env.SHOP_ACTIVATED !== 'false';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all caches (useful after settings update).
|
||||||
|
*/
|
||||||
|
export function invalidateCache() {
|
||||||
|
commissionCache = null;
|
||||||
|
commissionCachedAt = 0;
|
||||||
|
shopActivatedCache = null;
|
||||||
|
shopActivatedCachedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getCommissionWallets,
|
||||||
|
getShopActivated,
|
||||||
|
invalidateCache,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user