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
5 changes: 3 additions & 2 deletions packages/app/control/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"dependencies": {
"@auth/core": "^0.40.0",
"@auth/prisma-adapter": "^2.10.0",
"@coinbase/cdp-sdk": "^1.34.0",
"@coinbase/x402": "^0.6.4",
"@hookform/resolvers": "^5.2.1",
"@icons-pack/react-simple-icons": "^13.7.0",
"@merit-systems/sdk": "0.0.8",
Expand Down Expand Up @@ -88,7 +90,6 @@
"autonumeric": "^4.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"@coinbase/x402": "^0.6.4",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
Expand Down Expand Up @@ -137,9 +138,9 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@faker-js/faker": "^9.9.0",
"@next/eslint-plugin-next": "^15.5.3",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@next/eslint-plugin-next": "^15.5.3",
"@types/node": "^20",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- CreateEnum
CREATE TYPE "EnumTransactionType" AS ENUM ('X402', 'BALANCE');

-- DropForeignKey
ALTER TABLE "public"."transactions" DROP CONSTRAINT "transactions_echoAppId_fkey";

-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "transactionType" "EnumTransactionType" NOT NULL DEFAULT 'BALANCE',
ALTER COLUMN "userId" DROP NOT NULL,
ALTER COLUMN "echoAppId" DROP NOT NULL;

-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_echoAppId_fkey" FOREIGN KEY ("echoAppId") REFERENCES "echo_apps"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "echoProfit" DECIMAL(65,14) NOT NULL DEFAULT 0.0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "EnumPayoutType" ADD VALUE 'ECHO_PROFIT';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "EnumPayoutType" ADD VALUE 'APP_PROFIT';
19 changes: 14 additions & 5 deletions packages/app/control/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ enum EnumPayoutStatus {
enum EnumPayoutType {
MARKUP
REFERRAL
ECHO_PROFIT
APP_PROFIT
}

model Payout {
Expand Down Expand Up @@ -301,32 +303,39 @@ enum EnumPaymentSource {
balance
}

enum EnumTransactionType {
X402
BALANCE
}

model Transaction {
id String @id @default(uuid()) @db.Uuid
transactionMetadataId String? @db.Uuid
totalCost Decimal @default(0.0) @db.Decimal(65, 14)
appProfit Decimal @default(0.0) @db.Decimal(65, 14)
echoProfit Decimal @default(0.0) @db.Decimal(65, 14)
markUpProfit Decimal @default(0.0) @db.Decimal(65, 14)
referralProfit Decimal @default(0.0) @db.Decimal(65, 14)
rawTransactionCost Decimal @default(0.0) @db.Decimal(65, 14)
status String?
isArchived Boolean @default(false)
archivedAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
userId String @db.Uuid
echoAppId String @db.Uuid
transactionType EnumTransactionType @default(BALANCE)
userId String? @db.Uuid
echoAppId String? @db.Uuid
apiKeyId String? @db.Uuid
markUpId String? @db.Uuid
spendPoolId String? @db.Uuid
userSpendPoolUsageId String? @db.Uuid
referralCodeId String? @db.Uuid
referrerRewardId String? @db.Uuid
apiKey ApiKey? @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
echoApp EchoApp @relation(fields: [echoAppId], references: [id])
echoApp EchoApp? @relation(fields: [echoAppId], references: [id])
markUp MarkUp? @relation(fields: [markUpId], references: [id])
spendPool SpendPool? @relation(fields: [spendPoolId], references: [id])
transactionMetadata TransactionMetadata? @relation(fields: [transactionMetadataId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userSpendPoolUsage UserSpendPoolUsage? @relation(fields: [userSpendPoolUsageId], references: [id])
referralCode ReferralCode? @relation(fields: [referralCodeId], references: [id])
referrerReward ReferralReward? @relation(fields: [referrerRewardId], references: [id])
Expand Down Expand Up @@ -506,4 +515,4 @@ model VideoGenerationX402 {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
echoApp EchoApp? @relation(fields: [echoAppId], references: [id], onDelete: Cascade)
@@map("video_generation_x402")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
'use client';

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { api } from '@/trpc/client';
import { toast } from 'sonner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useState } from 'react';

export function AppX402ProfitTotal() {
const [processingAppIds, setProcessingAppIds] = useState<Set<string>>(
new Set()
);
const [isSendingAll, setIsSendingAll] = useState(false);
const utils = api.useUtils();

const { data: totalProfit, isLoading: isTotalLoading } =
api.admin.wallet.getX402AppProfit.useQuery();

const { data: appBreakdown, isLoading: isBreakdownLoading } =
api.admin.wallet.getX402AppProfitByApp.useQuery();

const payoutMutation = api.admin.wallet.payoutX402AppProfit.useMutation({
onSuccess: data => {
toast.success('Payout successful!', {
description: `Sent to ECHO_PAYOUTS. Tx: ${data.userOpHash.slice(0, 10)}...`,
});
void utils.admin.wallet.getX402AppProfit.invalidate();
void utils.admin.wallet.getX402AppProfitByApp.invalidate();
},
onError: error => {
toast.error('Payout failed', {
description: error.message,
});
},
onSettled: (data, error, variables) => {
setProcessingAppIds(prev => {
const next = new Set(prev);
next.delete(variables.appId);
return next;
});
},
});

const handlePayout = (appId: string, amount: number) => {
if (amount <= 0) {
toast.error('Invalid amount', {
description: 'Amount must be greater than 0',
});
return;
}

setProcessingAppIds(prev => new Set(prev).add(appId));
payoutMutation.mutate({ appId, amount });
};

const handleSendAll = async () => {
if (!appBreakdown || appBreakdown.length === 0) {
toast.error('No apps to payout');
return;
}

const appsWithProfit = appBreakdown.filter(app => app.remainingProfit > 0);

if (appsWithProfit.length === 0) {
toast.error('No apps with remaining profit');
return;
}

setIsSendingAll(true);

for (const app of appsWithProfit) {
setProcessingAppIds(prev => new Set(prev).add(app.appId));
payoutMutation.mutate({
appId: app.appId,
amount: app.remainingProfit,
});
}

setIsSendingAll(false);
};

return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>App X402 Profit Total</CardTitle>
<CardDescription>
Total unclaimed profit generated by apps from X402 transactions
</CardDescription>
</CardHeader>

<CardContent className="space-y-6">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Total App Profit
</h3>
{isTotalLoading ? (
<Skeleton className="h-10 w-32" />
) : (
<div className="text-3xl font-bold text-green-600">
${(totalProfit ?? 0).toFixed(6)}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">
Available for payout to applications
</p>
</div>

<p className="text-sm text-muted-foreground">
The App Profit represents the sum of all appProfit from X402
transactions minus any payouts already made to applications.
</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Profit Breakdown by Application</CardTitle>
<CardDescription>
X402 profit generated by each application
</CardDescription>
</div>
{appBreakdown &&
appBreakdown.some(app => app.remainingProfit > 0) && (
<Button
onClick={handleSendAll}
disabled={isSendingAll || payoutMutation.isPending}
variant="default"
>
{isSendingAll ? 'Processing...' : 'Send All'}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isBreakdownLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : !appBreakdown || appBreakdown.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No app profit data available
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Application</TableHead>
<TableHead className="text-right">Total Profit</TableHead>
<TableHead className="text-right">Total Payouts</TableHead>
<TableHead className="text-right">Remaining</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{appBreakdown.map(app => (
<TableRow key={app.appId}>
<TableCell className="font-medium">
{app.appName}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${app.totalProfit.toFixed(6)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${app.totalPayouts.toFixed(6)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
<span
className={
app.remainingProfit > 0
? 'text-green-600 font-semibold'
: ''
}
>
${app.remainingProfit.toFixed(6)}
</span>
</TableCell>
<TableCell className="text-right">
<Button
onClick={() =>
handlePayout(app.appId, app.remainingProfit)
}
disabled={
app.remainingProfit <= 0 ||
processingAppIds.has(app.appId)
}
size="sm"
variant="outline"
>
{processingAppIds.has(app.appId)
? 'Sending...'
: 'Send'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
Loading
Loading