From 908b496307820610272ed68c61faeef767a4803a Mon Sep 17 00:00:00 2001 From: Emil F Date: Wed, 1 Apr 2026 10:43:19 +0300 Subject: [PATCH 01/16] feat(ImprovePwAI3UX): admin per-user credit detail page with refund support - Capture receipt.from (EVM wallet address) at intent confirmation time and store it in a new intents.from_address column (migration included) - Add purchased_credits.refunded + refunded_at columns (migration included) - Repository: markAsRefunded() zeros remaining bytes + sets refunded/refunded_at; getByUserPublicId() joins purchased_credits with intents to return price + wallet data per batch - Use cases: getUserBatches (admin-only, by userPublicId) + refundBatch (idempotent) - API routes: GET /credits/batches/user/:userPublicId and POST /credits/batches/:id/refund - Frontend: AdminUserCredits component shows full purchase history per user with date, status, expiry, original/consumed/remaining bytes, AI3 paid, EVM wallet address, and a "Mark Refunded" button per batch - AllBatchesTable User column is now a clickable link to the new per-user page - New Next.js page: /[chain]/drive/admin/credits/[userPublicId] - Added ROUTES.adminUserCredits helper to @auto-drive/ui Co-Authored-By: Claude Sonnet 4.6 --- .../20260401000000-intent-from-address.js | 53 ++++ ...20260401000001-purchased-credits-refund.js | 53 ++++ ...0260401000000-intent-from-address-down.sql | 1 + .../20260401000000-intent-from-address-up.sql | 12 + ...01000001-purchased-credits-refund-down.sql | 3 + ...0401000001-purchased-credits-refund-up.sql | 12 + apps/backend/src/app/controllers/credits.ts | 71 +++++ apps/backend/src/core/users/credits.ts | 50 +++ apps/backend/src/core/users/intents.ts | 3 + .../repositories/users/intents.ts | 8 +- .../repositories/users/purchasedCredits.ts | 85 ++++++ .../services/paymentManager/index.ts | 3 + .../admin/credits/[userPublicId]/page.tsx | 18 ++ .../views/AdminPanel/AdminCredits.tsx | 23 +- .../views/AdminPanel/AdminUserCredits.tsx | 284 ++++++++++++++++++ apps/frontend/src/services/api.ts | 70 +++++ packages/models/src/users/intent.ts | 4 + packages/models/src/users/purchasedCredit.ts | 8 + packages/ui/src/constants/routes.ts | 2 + 19 files changed, 756 insertions(+), 7 deletions(-) create mode 100644 apps/backend/migrations/20260401000000-intent-from-address.js create mode 100644 apps/backend/migrations/20260401000001-purchased-credits-refund.js create mode 100644 apps/backend/migrations/sqls/20260401000000-intent-from-address-down.sql create mode 100644 apps/backend/migrations/sqls/20260401000000-intent-from-address-up.sql create mode 100644 apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql create mode 100644 apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql create mode 100644 apps/frontend/src/app/[chain]/drive/admin/credits/[userPublicId]/page.tsx create mode 100644 apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx diff --git a/apps/backend/migrations/20260401000000-intent-from-address.js b/apps/backend/migrations/20260401000000-intent-from-address.js new file mode 100644 index 000000000..fb05caf7d --- /dev/null +++ b/apps/backend/migrations/20260401000000-intent-from-address.js @@ -0,0 +1,53 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260401000000-intent-from-address-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260401000000-intent-from-address-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/backend/migrations/20260401000001-purchased-credits-refund.js b/apps/backend/migrations/20260401000001-purchased-credits-refund.js new file mode 100644 index 000000000..99b345a22 --- /dev/null +++ b/apps/backend/migrations/20260401000001-purchased-credits-refund.js @@ -0,0 +1,53 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260401000001-purchased-credits-refund-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260401000001-purchased-credits-refund-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/backend/migrations/sqls/20260401000000-intent-from-address-down.sql b/apps/backend/migrations/sqls/20260401000000-intent-from-address-down.sql new file mode 100644 index 000000000..8f9267da8 --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000000-intent-from-address-down.sql @@ -0,0 +1 @@ +ALTER TABLE intents DROP COLUMN IF EXISTS from_address; diff --git a/apps/backend/migrations/sqls/20260401000000-intent-from-address-up.sql b/apps/backend/migrations/sqls/20260401000000-intent-from-address-up.sql new file mode 100644 index 000000000..a37d00eed --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000000-intent-from-address-up.sql @@ -0,0 +1,12 @@ +-- Add from_address to intents table. +-- +-- Captures the EVM wallet address that sent the on-chain payment so admins +-- can identify the payer and initiate refunds without needing to look up the +-- transaction externally. Populated by the payment manager when it processes +-- the TransactionReceipt for each confirmed intent. +-- +-- Nullable because: +-- 1. Existing intents confirmed before this migration have no stored address. +-- 2. Intents that expire or fail before confirmation never receive a receipt. + +ALTER TABLE intents ADD COLUMN from_address VARCHAR(255); diff --git a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql new file mode 100644 index 000000000..647fbfe2b --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql @@ -0,0 +1,3 @@ +ALTER TABLE purchased_credits + DROP COLUMN IF EXISTS refunded, + DROP COLUMN IF EXISTS refunded_at; diff --git a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql new file mode 100644 index 000000000..8ca9f54ab --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql @@ -0,0 +1,12 @@ +-- Add refund tracking columns to purchased_credits. +-- +-- refunded: TRUE once an admin has marked this batch as manually refunded +-- via POST /credits/batches/:id/refund. Zeroing out the +-- remaining bytes happens in the same operation so the user +-- cannot continue using credits they have been refunded for. +-- +-- refunded_at: Timestamp of the refund action for the audit trail. + +ALTER TABLE purchased_credits + ADD COLUMN refunded BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN refunded_at TIMESTAMP WITH TIME ZONE; diff --git a/apps/backend/src/app/controllers/credits.ts b/apps/backend/src/app/controllers/credits.ts index 884911870..92f11655e 100644 --- a/apps/backend/src/app/controllers/credits.ts +++ b/apps/backend/src/app/controllers/credits.ts @@ -155,6 +155,77 @@ creditsController.get( }), ) +// --------------------------------------------------------------------------- +// GET /credits/batches/user/:userPublicId +// Admin-only: all credit batches for a specific user, newest-first. +// Each row includes intent fields (paymentAmount, shannonsPerByte, txHash, +// fromAddress) so the admin can calculate the AI3 price paid and identify +// the wallet used for the on-chain payment. +// Returns 403 for non-admin callers. +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches/user/:userPublicId', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { userPublicId } = req.params + + const result = await handleInternalErrorResult( + CreditsUseCases.getUserBatches(user, userPublicId), + 'Failed to get user credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((batch) => ({ + ...serializeCredit(batch), + userPublicId: batch.userPublicId, + paymentAmount: batch.paymentAmount?.toString() ?? null, + shannonsPerByte: batch.shannonsPerByte.toString(), + txHash: batch.txHash ?? null, + fromAddress: batch.fromAddress ?? null, + })), + ) + }), +) + +// --------------------------------------------------------------------------- +// POST /credits/batches/:id/refund +// Admin-only: marks a credit batch as refunded (zeros remaining bytes, +// sets refunded = true). Idempotent — safe to call multiple times. +// Returns 403 for non-admin callers, 404 if the batch is not found. +// --------------------------------------------------------------------------- + +creditsController.post( + '/batches/:id/refund', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { id } = req.params + + const result = await handleInternalErrorResult( + CreditsUseCases.refundBatch(user, id), + 'Failed to refund credit batch', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json({ ok: true }) + }), +) + // --------------------------------------------------------------------------- // GET /credits/economics // Admin-only: system-wide credit stats (expiring totals, byte volumes). diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts index a017637af..3872b1a51 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -1,6 +1,7 @@ import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models' import { AdminCreditBatchRow, + AdminUserCreditBatchRow, purchasedCreditsRepository, } from '../../infrastructure/repositories/users/purchasedCredits.js' import { AccountsUseCases } from './accounts.js' @@ -163,10 +164,59 @@ const getAllBatches = async ( return ok(rows) } +// --------------------------------------------------------------------------- +// getUserBatches +// Admin-only: full purchase history for a specific user identified by their +// userPublicId. Includes intent data (price, wallet address) for refund UX. +// Returns 403 for non-admin callers. +// --------------------------------------------------------------------------- + +const getUserBatches = async ( + executor: User, + userPublicId: string, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + const rows = await purchasedCreditsRepository.getByUserPublicId(userPublicId) + return ok(rows) +} + +// --------------------------------------------------------------------------- +// refundBatch +// Admin-only: zeros the remaining bytes for a specific credit batch and marks +// it as refunded. Idempotent — calling it on an already-refunded row is a +// no-op that still returns ok() so the UI can safely retry on network errors. +// Returns 403 for non-admin callers, 404 if the batch does not exist. +// --------------------------------------------------------------------------- + +const refundBatch = async ( + executor: User, + batchId: string, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const updated = await purchasedCreditsRepository.markAsRefunded(batchId) + if (!updated) { + return err(new Error('Credit batch not found')) + } + + logger.info('Admin marked credit batch as refunded', { + batchId, + adminPublicId: executor.publicId, + }) + + return ok(undefined) +} + export const CreditsUseCases = { getSummary, getBatches, getExpiringBatches, getEconomics, getAllBatches, + getUserBatches, + refundBatch, } diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index 0e187dc2d..333deefb8 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -146,9 +146,11 @@ const triggerWatchIntent = async ({ const markIntentAsConfirmed = async ({ intentId, paymentAmount, + fromAddress, }: { intentId: string paymentAmount: bigint + fromAddress?: string }) => { const intent = await intentsRepository.getById(intentId) if (!intent) { @@ -182,6 +184,7 @@ const markIntentAsConfirmed = async ({ ...intent, status: IntentStatus.CONFIRMED, paymentAmount, + fromAddress: fromAddress ?? intent.fromAddress, }), ) } diff --git a/apps/backend/src/infrastructure/repositories/users/intents.ts b/apps/backend/src/infrastructure/repositories/users/intents.ts index 8874cd0b1..ed2ad22e8 100644 --- a/apps/backend/src/infrastructure/repositories/users/intents.ts +++ b/apps/backend/src/infrastructure/repositories/users/intents.ts @@ -9,6 +9,7 @@ type DBIntent = { payment_amount: string shannons_per_byte: string expires_at: Date | null + from_address: string | null } const mapRows = (rows: DBIntent[]): Intent[] => { @@ -22,6 +23,7 @@ const mapRows = (rows: DBIntent[]): Intent[] => { : undefined, shannonsPerByte: BigInt(row.shannons_per_byte).valueOf(), expiresAt: row.expires_at ?? undefined, + fromAddress: row.from_address ?? undefined, })) } @@ -59,8 +61,9 @@ const updateIntent = async (intent: Intent): Promise => { const result = await db.query( `UPDATE intents SET status = $1, user_public_id = $2, tx_hash = $3, - payment_amount = $4, shannons_per_byte = $5, expires_at = $6 - WHERE id = $7 + payment_amount = $4, shannons_per_byte = $5, expires_at = $6, + from_address = $7 + WHERE id = $8 RETURNING *`, [ intent.status, @@ -69,6 +72,7 @@ const updateIntent = async (intent: Intent): Promise => { intent.paymentAmount?.toString() ?? null, intent.shannonsPerByte, intent.expiresAt ?? null, + intent.fromAddress ?? null, intent.id, ], ) diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 0361a9064..47cf016aa 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -23,6 +23,8 @@ type DBPurchasedCredit = { purchased_at: Date expires_at: Date expired: boolean + refunded: boolean + refunded_at: Date | null created_at: Date updated_at: Date } @@ -38,6 +40,8 @@ const mapRow = (row: DBPurchasedCredit): PurchasedCredit => ({ purchasedAt: row.purchased_at, expiresAt: row.expires_at, expired: row.expired, + refunded: row.refunded, + refundedAt: row.refunded_at, createdAt: row.created_at, updatedAt: row.updated_at, }) @@ -548,6 +552,85 @@ const createPurchasedCreditWithCapCheck = async ( } } +// --------------------------------------------------------------------------- +// markAsRefunded +// Admin action: zero out remaining bytes for a single purchased_credits row +// and mark it as refunded. Called after an admin has processed an +// out-of-band refund (e.g. manual AI3 transfer back to the user's wallet). +// Returns the updated row so the caller can echo it back to the client. +// --------------------------------------------------------------------------- + +const markAsRefunded = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + `UPDATE purchased_credits + SET upload_bytes_remaining = 0, + download_bytes_remaining = 0, + refunded = TRUE, + refunded_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id], + ) + return result.rows[0] ? mapRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getByUserPublicId +// Admin view: all credit batches for a specific user (identified by their +// user_public_id), joined with key fields from the originating intent so the +// admin page can show the AI3 price paid and the EVM wallet used. +// Ordered newest-first. +// --------------------------------------------------------------------------- + +type DBPurchasedCreditWithIntent = DBPurchasedCredit & { + user_public_id: string + payment_amount: string | null + shannons_per_byte: string + tx_hash: string | null + from_address: string | null +} + +export type AdminUserCreditBatchRow = PurchasedCredit & { + userPublicId: string + paymentAmount: bigint | null + shannonsPerByte: bigint + txHash: string | null + fromAddress: string | null +} + +const mapRowWithIntent = ( + row: DBPurchasedCreditWithIntent, +): AdminUserCreditBatchRow => ({ + ...mapRow(row), + userPublicId: row.user_public_id, + paymentAmount: row.payment_amount ? BigInt(row.payment_amount) : null, + shannonsPerByte: BigInt(row.shannons_per_byte), + txHash: row.tx_hash ?? null, + fromAddress: row.from_address ?? null, +}) + +const getByUserPublicId = async ( + userPublicId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT pc.*, + i.user_public_id, + i.payment_amount, + i.shannons_per_byte, + i.tx_hash, + i.from_address + FROM purchased_credits pc + JOIN intents i ON i.id = pc.intent_id + WHERE i.user_public_id = $1 + ORDER BY pc.purchased_at DESC`, + [userPublicId], + ) + return result.rows.map(mapRowWithIntent) +} + // --------------------------------------------------------------------------- // getAllWithUserPublicId // Admin view: every credit batch across all users, joined with the @@ -589,4 +672,6 @@ export const purchasedCreditsRepository = { markExpiredCredits, getByAccountId, getAllWithUserPublicId, + markAsRefunded, + getByUserPublicId, } diff --git a/apps/backend/src/infrastructure/services/paymentManager/index.ts b/apps/backend/src/infrastructure/services/paymentManager/index.ts index 0cce7f9d8..acc008717 100644 --- a/apps/backend/src/infrastructure/services/paymentManager/index.ts +++ b/apps/backend/src/infrastructure/services/paymentManager/index.ts @@ -58,6 +58,9 @@ const watchTransaction = async (txHash: string) => { return IntentsUseCases.markIntentAsConfirmed({ intentId: log.args.intentId, paymentAmount: log.args.paymentAmount, + // receipt.from is the EVM wallet address that submitted the tx. + // Stored so admins can identify the payer and process refunds. + fromAddress: receipt.from, }) }), ) diff --git a/apps/frontend/src/app/[chain]/drive/admin/credits/[userPublicId]/page.tsx b/apps/frontend/src/app/[chain]/drive/admin/credits/[userPublicId]/page.tsx new file mode 100644 index 000000000..6e9e48af2 --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/admin/credits/[userPublicId]/page.tsx @@ -0,0 +1,18 @@ +import { AdminUserCredits } from '@/components/views/AdminPanel/AdminUserCredits'; +import { UserProtectedLayout } from '@/components/layouts/UserProtectedLayout'; + +export const dynamic = 'force-dynamic'; + +export default async function Page({ + params, +}: { + params: Promise<{ userPublicId: string }>; +}) { + const { userPublicId } = await params; + + return ( + + + + ); +} diff --git a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx index 4b2937d9b..3a2cbc652 100644 --- a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx +++ b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx @@ -5,13 +5,14 @@ import { useNetwork } from '../../../contexts/network'; import { formatBytes } from '../../../utils/number'; import { formatDate } from '../../../utils/time'; import { RefreshCw, AlertTriangle, RotateCcw } from 'lucide-react'; -import { Button } from '@auto-drive/ui'; +import { Button, ROUTES, type NetworkId } from '@auto-drive/ui'; import { getBatchStatus, STATUS_CLASSES, STATUS_LABEL } from '../../../utils/credits'; import type { AdminCreditBatch, CreditEconomicsResponse, OverCapIntent, } from '../../../services/api'; +import Link from 'next/link'; // --------------------------------------------------------------------------- // Economics summary card @@ -122,7 +123,13 @@ const OverCapPanel = ({ // All credit batches table // --------------------------------------------------------------------------- -const AllBatchesTable = ({ batches }: { batches: AdminCreditBatch[] }) => { +const AllBatchesTable = ({ + batches, + networkId, +}: { + batches: AdminCreditBatch[]; + networkId: NetworkId; +}) => { if (batches.length === 0) { return (

@@ -161,7 +168,13 @@ const AllBatchesTable = ({ batches }: { batches: AdminCreditBatch[] }) => { className='border-b border-border last:border-0' > - {batch.userPublicId.slice(0, 14)}… + + {batch.userPublicId.slice(0, 14)}… + { // --------------------------------------------------------------------------- export const AdminCredits = () => { - const { api } = useNetwork(); + const { api, network } = useNetwork(); const queryClient = useQueryClient(); const { data: economics, isLoading: economicsLoading } = @@ -311,7 +324,7 @@ export const AdminCredits = () => {

All Purchase Batches ({batches.length})

- + ); diff --git a/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx new file mode 100644 index 000000000..06cbecc44 --- /dev/null +++ b/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNetwork } from '../../../contexts/network'; +import { formatBytes } from '../../../utils/number'; +import { formatDate } from '../../../utils/time'; +import { + ArrowLeft, + RefreshCw, + CheckCircle2, + AlertTriangle, +} from 'lucide-react'; +import { Button, ROUTES } from '@auto-drive/ui'; +import { getBatchStatus, STATUS_CLASSES, STATUS_LABEL } from '../../../utils/credits'; +import type { AdminUserCreditBatch } from '../../../services/api'; +import Link from 'next/link'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Convert (paymentAmount shannons) / (shannonsPerByte) / (1e18 shannons per AI3) + * into a human-readable AI3 amount. + * + * paymentAmount — total shannons sent on-chain + * shannonsPerByte — price per byte in shannons at the time of purchase + * + * creditBytes = paymentAmount / shannonsPerByte (integer division) + * AI3 paid = paymentAmount / 1e18 + */ +const SHANNONS_PER_AI3 = BigInt('1000000000000000000') // 1e18 + +const formatAI3Paid = (paymentAmount: string | null): string => { + if (!paymentAmount) return '—'; + try { + const shannons = BigInt(paymentAmount); + // Format to 6 decimal places + const whole = shannons / SHANNONS_PER_AI3; + const remainder = shannons % SHANNONS_PER_AI3; + const decimals = remainder + .toString() + .padStart(18, '0') + .slice(0, 6) + .replace(/0+$/, ''); + return decimals ? `${whole}.${decimals} AI3` : `${whole} AI3`; + } catch { + return '—'; + } +}; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const AdminUserCredits = ({ + userPublicId, +}: { + userPublicId: string; +}) => { + const { api, network } = useNetwork(); + const queryClient = useQueryClient(); + const [refundingId, setRefundingId] = useState(null); + + const queryKey = ['adminUserCreditBatches', userPublicId]; + + const { data: batches = [], isLoading } = useQuery({ + queryKey, + queryFn: () => api.getUserCreditBatches(userPublicId), + staleTime: 30_000, + }); + + const { mutate: refund, isPending: isRefunding } = useMutation< + void, + Error, + string + >({ + mutationFn: async (batchId: string) => { + setRefundingId(batchId); + return api.refundCreditBatch(batchId); + }, + onSettled: () => { + setRefundingId(null); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + + return ( +
+ {/* Header */} +
+ + + +
+

Purchase History

+

+ {userPublicId} +

+
+ {isLoading && ( + + )} +
+ + {/* Summary row */} + {batches.length > 0 && ( +
+
+

Total batches

+

{batches.length}

+
+
+

Total purchased

+

+ {formatBytes( + batches.reduce( + (s, b) => s + Number(BigInt(b.uploadBytesOriginal)), + 0, + ), + 1, + )} +

+
+
+

Remaining

+

+ {formatBytes( + batches.reduce( + (s, b) => s + Number(BigInt(b.uploadBytesRemaining)), + 0, + ), + 1, + )} +

+
+
+

Refunded

+

+ {batches.filter((b) => b.refunded).length} +

+
+
+ )} + + {/* Batches table */} + {batches.length === 0 && !isLoading ? ( +

+ No credit batches found for this user. +

+ ) : ( +
+ + + + + + + + + + + + + + + + {batches.map((batch) => { + const status = getBatchStatus(batch); + const original = Number(BigInt(batch.uploadBytesOriginal)); + const remaining = Number(BigInt(batch.uploadBytesRemaining)); + const consumed = original - remaining; + + return ( + + {/* Date */} + + + {/* Status */} + + + {/* Expires */} + + + {/* Original */} + + + {/* Consumed */} + + + {/* Remaining */} + + + {/* AI3 paid */} + + + {/* EVM wallet */} + + + {/* Refund action */} + + + ); + })} + +
DateStatusExpiresOriginalConsumedRemainingAI3 PaidEVM WalletRefund
+ {formatDate(batch.purchasedAt)} + +
+ + {STATUS_LABEL[status]} + + {batch.refunded && ( + + + Refunded + + )} +
+
+ {batch.expired ? ( + + {formatDate(batch.expiresAt)} + + ) : ( + formatDate(batch.expiresAt) + )} + + {formatBytes(original, 1)} + + {formatBytes(consumed, 1)} + + {formatBytes(remaining, 1)} + + {formatAI3Paid(batch.paymentAmount)} + + {batch.fromAddress ? ( + + {batch.fromAddress.slice(0, 8)}… + {batch.fromAddress.slice(-6)} + + ) : ( + + )} + + {batch.refunded ? ( + + {batch.refundedAt + ? formatDate(batch.refundedAt) + : 'Refunded'} + + ) : ( + + )} +
+
+ )} +
+ ); +}; diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index e82057965..e95022666 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -59,6 +59,19 @@ export type OverCapIntent = { shannonsPerByte: string; expiresAt?: string; }; + +// Wire-format of rows from GET /credits/batches/user/:userPublicId (admin). +// Extends ExpiringCreditBatch with intent fields so the admin can see the +// AI3 price paid and the EVM wallet address used for the on-chain payment. +export type AdminUserCreditBatch = ExpiringCreditBatch & { + userPublicId: string; + paymentAmount: string | null; + shannonsPerByte: string; + txHash: string | null; + fromAddress: string | null; + refunded: boolean; + refundedAt: string | null; +}; import { getAuthSession } from 'utils/auth'; import { uploadFileContent } from 'utils/file'; @@ -675,4 +688,61 @@ export const createApiService = ({ throw new Error(`Network response was not ok: ${response.statusText}`); } }, + + // ------------------------------------------------------------------------- + // Admin: get all credit batches for a specific user with intent data + // GET /credits/batches/user/:userPublicId + // ------------------------------------------------------------------------- + + getUserCreditBatches: async ( + userPublicId: string, + ): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch( + `${apiBaseUrl}/credits/batches/user/${encodeURIComponent(userPublicId)}`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'X-Auth-Provider': session.authProvider, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Network response was not ok: ${response.statusText}`); + } + + return response.json() as Promise; + }, + + // ------------------------------------------------------------------------- + // Admin: mark a credit batch as refunded + // POST /credits/batches/:id/refund + // ------------------------------------------------------------------------- + + refundCreditBatch: async (batchId: string): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch( + `${apiBaseUrl}/credits/batches/${encodeURIComponent(batchId)}/refund`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'X-Auth-Provider': session.authProvider, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Network response was not ok: ${response.statusText}`); + } + }, }); diff --git a/packages/models/src/users/intent.ts b/packages/models/src/users/intent.ts index 7ff52a7f6..584341844 100644 --- a/packages/models/src/users/intent.ts +++ b/packages/models/src/users/intent.ts @@ -24,6 +24,10 @@ export const IntentSchema = z.object({ // Price-lock window: set at creation, intent is rejected after this time. // NULL for intents created before this feature was introduced. expiresAt: z.date().optional(), + // EVM wallet address that sent the on-chain payment. Populated by the + // payment manager from TransactionReceipt.from when the tx is confirmed. + // NULL for intents confirmed before this field was introduced. + fromAddress: z.string().optional(), }); export type Intent = z.infer; diff --git a/packages/models/src/users/purchasedCredit.ts b/packages/models/src/users/purchasedCredit.ts index 8aa04cb1b..aba0126cb 100644 --- a/packages/models/src/users/purchasedCredit.ts +++ b/packages/models/src/users/purchasedCredit.ts @@ -12,6 +12,14 @@ export const PurchasedCreditSchema = z.object({ /** Every purchased credit row has a hard expiry date — never null. */ expiresAt: z.date(), expired: z.boolean(), + /** + * True once an admin has processed an out-of-band refund for this batch. + * Set by POST /credits/batches/:id/refund — zeros remaining bytes and + * records the timestamp so the admin dashboard can show refund history. + */ + refunded: z.boolean(), + /** Timestamp when the refund was recorded, or null if not yet refunded. */ + refundedAt: z.date().nullable(), createdAt: z.date(), updatedAt: z.date(), }); diff --git a/packages/ui/src/constants/routes.ts b/packages/ui/src/constants/routes.ts index 05b04be7a..77955a2dc 100644 --- a/packages/ui/src/constants/routes.ts +++ b/packages/ui/src/constants/routes.ts @@ -59,6 +59,8 @@ export const ROUTES = { admin: (networkId: NetworkId) => `/${networkId}/drive/admin`, adminOrganization: (networkId: NetworkId, organizationId: string) => `/${networkId}/drive/admin/organization/${organizationId}`, + adminUserCredits: (networkId: NetworkId, userPublicId: string) => + `/${networkId}/drive/admin/credits/${encodeURIComponent(userPublicId)}`, explorer: (networkId: NetworkId) => `/${networkId}/explorer`, publicFileDetails: (networkId: NetworkId, cid: string) => `/${networkId}/explorer/${cid}`, From 23d5a2c6c08b1cc39f2c2ee59f633e57f5cf9411 Mon Sep 17 00:00:00 2001 From: Emil F Date: Wed, 1 Apr 2026 11:02:14 +0300 Subject: [PATCH 02/16] refactor: drop redundant refunded boolean, use refunded_at IS NOT NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refunded_at being non-null is already the canonical signal that a refund occurred. The separate refunded boolean was redundant — remove it from the migration, DB type, model, and frontend type. All refund-state checks now read batch.refundedAt !== null. Co-Authored-By: Claude Sonnet 4.6 --- ...260401000001-purchased-credits-refund-down.sql | 1 - ...20260401000001-purchased-credits-refund-up.sql | 15 +++++++-------- .../repositories/users/purchasedCredits.ts | 3 --- .../views/AdminPanel/AdminUserCredits.tsx | 10 ++++------ apps/frontend/src/services/api.ts | 2 +- packages/models/src/users/purchasedCredit.ts | 9 ++++----- 6 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql index 647fbfe2b..2da175064 100644 --- a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql @@ -1,3 +1,2 @@ ALTER TABLE purchased_credits - DROP COLUMN IF EXISTS refunded, DROP COLUMN IF EXISTS refunded_at; diff --git a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql index 8ca9f54ab..cd042c180 100644 --- a/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql @@ -1,12 +1,11 @@ --- Add refund tracking columns to purchased_credits. +-- Add refund tracking to purchased_credits. -- --- refunded: TRUE once an admin has marked this batch as manually refunded --- via POST /credits/batches/:id/refund. Zeroing out the --- remaining bytes happens in the same operation so the user --- cannot continue using credits they have been refunded for. --- --- refunded_at: Timestamp of the refund action for the audit trail. +-- refunded_at: Timestamp of when an admin marked this batch as manually +-- refunded via POST /credits/batches/:id/refund. NULL means +-- not refunded. The presence of a timestamp is the canonical +-- source of truth — no separate boolean needed. +-- Remaining bytes are zeroed out in the same operation so the +-- user cannot continue using credits they have been refunded for. ALTER TABLE purchased_credits - ADD COLUMN refunded BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN refunded_at TIMESTAMP WITH TIME ZONE; diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 47cf016aa..7d0a1096a 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -23,7 +23,6 @@ type DBPurchasedCredit = { purchased_at: Date expires_at: Date expired: boolean - refunded: boolean refunded_at: Date | null created_at: Date updated_at: Date @@ -40,7 +39,6 @@ const mapRow = (row: DBPurchasedCredit): PurchasedCredit => ({ purchasedAt: row.purchased_at, expiresAt: row.expires_at, expired: row.expired, - refunded: row.refunded, refundedAt: row.refunded_at, createdAt: row.created_at, updatedAt: row.updated_at, @@ -566,7 +564,6 @@ const markAsRefunded = async (id: string): Promise => { `UPDATE purchased_credits SET upload_bytes_remaining = 0, download_bytes_remaining = 0, - refunded = TRUE, refunded_at = NOW(), updated_at = NOW() WHERE id = $1 diff --git a/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx index 06cbecc44..9e7f0ba89 100644 --- a/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx +++ b/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx @@ -146,7 +146,7 @@ export const AdminUserCredits = ({

Refunded

- {batches.filter((b) => b.refunded).length} + {batches.filter((b) => b.refundedAt !== null).length}

@@ -198,7 +198,7 @@ export const AdminUserCredits = ({ > {STATUS_LABEL[status]} - {batch.refunded && ( + {batch.refundedAt !== null && ( Refunded @@ -252,11 +252,9 @@ export const AdminUserCredits = ({ {/* Refund action */} - {batch.refunded ? ( + {batch.refundedAt !== null ? ( - {batch.refundedAt - ? formatDate(batch.refundedAt) - : 'Refunded'} + {formatDate(batch.refundedAt)} ) : ( + ))} + + + + {/* Cap-exceeded warning */} + {capExceeded && maxPurchasableMib !== null && ( +
+ + + Exceeds your remaining cap.{' '} + Maximum you can purchase:{' '} + {formatBytes(maxPurchasableMib * 1024 * 1024, 2)} + +
+ )} + + {/* Zero-amount hint */} + {effectiveMib === 0 && inputValue !== '' && ( +
+ Enter an amount greater than 0. +
+ )} + + )} + + {/* Price breakdown (read-only) */} + {formatBytes(effectiveMib * 1024 * 1024, 2)}
} /> onChangeAi3(e.target.value)} - /> - ) : ( - - {formatCreditsInMbAsAi3(Number(sizeMB)).toFixed(2)} AI3 - - ) + {ai3Amount > 0 ? `${ai3Amount.toFixed(6)} AI3` : '—'} } /> - ${formatCreditsInMbAsUsd(Number(sizeMB)).toFixed(2)} + {usdAmount > 0 ? `$${usdAmount.toFixed(2)}` : '—'} } /> @@ -169,7 +289,7 @@ export const PurchaseStep2ConnectWallet = ({ label='Total' value={ - {formatCreditsInMbAsAi3(Number(sizeMB)).toFixed(2)} AI3 + {ai3Amount > 0 ? `${ai3Amount.toFixed(2)} AI3` : '—'} } accent @@ -179,6 +299,7 @@ export const PurchaseStep2ConnectWallet = ({ + {/* Right: Payment summary */}
@@ -191,10 +312,9 @@ export const PurchaseStep2ConnectWallet = ({ label='After Purchase' value={ - {formatBytes( - currentPurchasedBytes + Number(sizeMB) * 1024 * 1024, - 2, - )} + {effectiveMib > 0 + ? formatBytes(afterPurchaseBytes, 2) + : formatBytes(currentPurchasedBytes, 2)} } className='rounded-md bg-primary/20 p-4' @@ -205,8 +325,9 @@ export const PurchaseStep2ConnectWallet = ({ Back
diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx index f1c4b1055..37719b7bf 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx @@ -27,14 +27,14 @@ const PACKAGES: PackageOption[] = [ id: 'starter', title: 'Starter', creditsInMB: 10, - sizeLabel: '10MB', + sizeLabel: '10 MB', baseFeatures: ['Permanent storage', 'Instant activation'], }, { id: 'pro', title: 'Professional', creditsInMB: 100, - sizeLabel: '100MB', + sizeLabel: '100 MB', popular: true, baseFeatures: ['Permanent storage', 'Instant activation'], }, @@ -42,7 +42,7 @@ const PACKAGES: PackageOption[] = [ id: 'ent', title: 'Enterprise', creditsInMB: 1024, - sizeLabel: '1GB', + sizeLabel: '1 GB', baseFeatures: ['Permanent storage', 'Instant activation'], }, { diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx index b0a50fb1d..256ea79c4 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx @@ -39,7 +39,7 @@ export const PurchaseStep4Success = ({ {sizeMB}MiB} + value={{sizeMB} MB} /> - {Number(context.sizeMB)} MiB + {Number(context.sizeMB)} MB } /> diff --git a/apps/frontend/src/utils/purchaseCredits.ts b/apps/frontend/src/utils/purchaseCredits.ts index fd97fc4e0..67d3fd01f 100644 --- a/apps/frontend/src/utils/purchaseCredits.ts +++ b/apps/frontend/src/utils/purchaseCredits.ts @@ -2,8 +2,12 @@ * Pure utility functions for the purchase-credits custom-amount UX. * Extracted so they can be unit-tested without a React environment. * - * All internal sizes use MiB. We treat MB=MiB, GB=GiB, TB=TiB to match the - * existing package definitions (e.g. the "1GB" preset = 1,024 MiB). + * All internal sizes use MiB (2^20 bytes). The unit toggle displays the + * familiar consumer labels MB / GB / TB, but uses binary multipliers + * throughout (matching how Windows, most consumer software, and the rest + * of this codebase count storage): + * 1 "GB" here = 1,024 MB = 1,024 MiB + * 1 "TB" here = 1,024 GB = 1,048,576 MiB */ // --------------------------------------------------------------------------- @@ -13,11 +17,11 @@ export const UNITS = ['MB', 'GB', 'TB'] as const; export type Unit = (typeof UNITS)[number]; -/** Multiplier from the given unit to MiB. */ +/** Multiplier from the displayed unit label to MiB (binary). */ export const MIB_PER_UNIT: Record = { MB: 1, - GB: 1024, - TB: 1024 * 1024, + GB: 1024, // 1 "GB" = 1,024 MiB + TB: 1024 * 1024, // 1 "TB" = 1,048,576 MiB }; // --------------------------------------------------------------------------- @@ -25,8 +29,8 @@ export const MIB_PER_UNIT: Record = { // --------------------------------------------------------------------------- /** - * Pick the most human-readable unit for a given MiB value. - * 0 / negative values fall back to MB. + * Pick the most human-readable IEC unit for a given MiB value. + * 0 / negative values fall back to MiB. */ export const bestUnit = (mib: number): Unit => { if (mib >= MIB_PER_UNIT.TB) return 'TB'; @@ -35,8 +39,8 @@ export const bestUnit = (mib: number): Unit => { }; /** - * Convert a MiB value to the display string in a given unit, trimmed to 4 - * significant figures with no trailing zeros. + * Convert a MiB value to the display string in a given IEC unit, trimmed to + * 4 significant figures with no trailing zeros. * Returns '' for non-positive values. */ export const mibToDisplay = (mib: number, unit: Unit): string => { @@ -53,8 +57,8 @@ export const mibToDisplay = (mib: number, unit: Unit): string => { * Sanitise a raw string from a numeric text input: * - Strips any character that is not a digit or decimal point. * - Collapses multiple decimal points, keeping only the first. - * - Preserves at most one leading digit (no "007" style leading zeros except - * when the value starts with "0."). + * - Negative signs are stripped (result is treated as 0 / invalid by the + * caller — inputToMib returns 0 for non-positive values). * * Returns the sanitised string (may be empty). */ @@ -66,7 +70,7 @@ export const sanitizeAmountInput = (raw: string): string => // --------------------------------------------------------------------------- /** - * Convert a sanitised input string + unit into whole MiB. + * Convert a sanitised input string + IEC unit into whole MiB. * Returns 0 for any non-positive or non-finite value. */ export const inputToMib = (value: string, unit: Unit): number => { From 3f2472510fa26150583e2e5960e531c098a4c0b1 Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 2 Apr 2026 08:56:25 -0400 Subject: [PATCH 15/16] feat(frontend): add binary-units info tooltip to custom amount selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a small ⓘ icon next to the MB/GB/TB toggle on the custom amount screen. Hovering reveals a tooltip that explains binary storage sizing in plain language: - "We use binary units — like most storage hardware" - 1 GB = 1,024 MB (not 1,000) - 1 TB = 1,024 GB (not 1,000) Users who don't care see only a subtle muted icon; curious users get the full picture without cluttering the UI. Co-Authored-By: Claude Sonnet 4.6 --- .../steps/Step2_ConfirmPurchase.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx index 1c735ade4..f84c126cb 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx @@ -4,7 +4,7 @@ import { Button } from '@auto-drive/ui'; import { InfoRow } from '../atoms/InfoRow'; import { Section } from '../atoms/Section'; import { useCallback, useMemo, useState, useEffect } from 'react'; -import { Zap, AlertTriangle } from 'lucide-react'; +import { Zap, AlertTriangle, Info } from 'lucide-react'; import { CreditCurrentPrice } from '../CreditCurrentPrice'; import { GoBackButton } from '../../../atoms/GoBackButton'; import { usePrices } from '../../../../hooks/usePrices'; @@ -223,6 +223,21 @@ export const PurchaseStep2ConnectWallet = ({ ))}
+ {/* Binary-units info bubble */} +
+ +
+

About these units

+

+ We use binary units — like most storage hardware. + That means: +

+
    +
  • 1 GB = 1,024 MB (not 1,000)
  • +
  • 1 TB = 1,024 GB (not 1,000)
  • +
+
+
{/* Cap-exceeded warning */} From 37bf0afd990530ad9ec0eec44ecc951afb15500b Mon Sep 17 00:00:00 2001 From: Emil Fattakhov <66026548+EmilFattakhov@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:55:21 -0400 Subject: [PATCH 16/16] Apply suggestions from code review Co-authored-by: Jim Counter --- .../views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx index f84c126cb..106cd39cc 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx @@ -233,8 +233,8 @@ export const PurchaseStep2ConnectWallet = ({ That means:

    -
  • 1 GB = 1,024 MB (not 1,000)
  • -
  • 1 TB = 1,024 GB (not 1,000)
  • +
  • 1 GB = 1,024 MiB (not 1,000 MB)
  • +
  • 1 TB = 1,024 GiB (not 1,000 GB)