diff --git a/apps/backend/__tests__/unit/PaymentManager.spec.ts b/apps/backend/__tests__/unit/PaymentManager.spec.ts index c4e463a00..5ac8731b9 100644 --- a/apps/backend/__tests__/unit/PaymentManager.spec.ts +++ b/apps/backend/__tests__/unit/PaymentManager.spec.ts @@ -55,12 +55,14 @@ describe('PaymentManager', () => { const txHash = '0xabc456' const intentId = '0xintent123' const paymentAmount = 100n + const fromAddress = '0xSenderWallet' config.paymentManager.contractAddress = '0xContractAddress' jest .spyOn(paymentManager._viemClient, 'waitForTransactionReceipt') .mockResolvedValue({ + from: fromAddress, logs: [ { address: '0xContractAddress', @@ -90,11 +92,13 @@ describe('PaymentManager', () => { await paymentManager.watchTransaction(txHash) - // The function should process the logs and attempt to mark intents + // The function should process the logs and attempt to mark intents, + // passing fromAddress captured from receipt.from. expect(markIntentSpy).toHaveBeenCalledTimes(1) expect(markIntentSpy).toHaveBeenCalledWith({ intentId, paymentAmount, + fromAddress, }) }) diff --git a/apps/backend/__tests__/unit/useCases/credits.spec.ts b/apps/backend/__tests__/unit/useCases/credits.spec.ts index 3a57ca84b..cd0570ef2 100644 --- a/apps/backend/__tests__/unit/useCases/credits.spec.ts +++ b/apps/backend/__tests__/unit/useCases/credits.spec.ts @@ -65,6 +65,7 @@ const makeCreditRow = ( purchasedAt: now, expiresAt: FUTURE_EXPIRY, expired: false, + refundedAt: null, createdAt: now, updatedAt: now, ...overrides, 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..2da175064 --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-down.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchased_credits + 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..cd042c180 --- /dev/null +++ b/apps/backend/migrations/sqls/20260401000001-purchased-credits-refund-up.sql @@ -0,0 +1,11 @@ +-- Add refund tracking to purchased_credits. +-- +-- 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_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 fa10891a5..522eff7bf 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -1,11 +1,12 @@ import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models' import { AdminCreditBatchRow, + AdminUserCreditBatchRow, purchasedCreditsRepository, } from '../../infrastructure/repositories/users/purchasedCredits.js' import { AccountsUseCases } from './accounts.js' import { config } from '../../config.js' -import { ForbiddenError } from '../../errors/index.js' +import { ForbiddenError, NotFoundError } from '../../errors/index.js' import { err, ok, Result } from 'neverthrow' import { hasGoogleAuth } from '../featureFlags/index.js' import { createLogger } from '../../infrastructure/drivers/logger.js' @@ -158,10 +159,66 @@ 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 { found, row } = await purchasedCreditsRepository.markAsRefunded(batchId) + if (!found) { + return err(new NotFoundError('Credit batch not found')) + } + + if (row) { + logger.info('Admin marked credit batch as refunded', { + batchId, + adminPublicId: executor.publicId, + }) + } else { + logger.info('Credit batch already refunded, no-op', { + 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 603e20939..e747e524f 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -23,6 +23,7 @@ type DBPurchasedCredit = { purchased_at: Date expires_at: Date expired: boolean + refunded_at: Date | null created_at: Date updated_at: Date } @@ -38,6 +39,7 @@ const mapRow = (row: DBPurchasedCredit): PurchasedCredit => ({ purchasedAt: row.purchased_at, expiresAt: row.expires_at, expired: row.expired, + refundedAt: row.refunded_at, createdAt: row.created_at, updatedAt: row.updated_at, }) @@ -547,6 +549,96 @@ 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<{ found: boolean; row: PurchasedCredit | null }> => { + const db = await getDatabase() + const result = await db.query( + `UPDATE purchased_credits + SET upload_bytes_remaining = 0, + download_bytes_remaining = 0, + refunded_at = NOW(), + updated_at = NOW() + WHERE id = $1 + AND refunded_at IS NULL + RETURNING *`, + [id], + ) + + if (result.rows[0]) { + return { found: true, row: mapRow(result.rows[0]) } + } + + const exists = await db.query<{ id: string }>( + 'SELECT id FROM purchased_credits WHERE id = $1', + [id], + ) + return { found: exists.rows.length > 0, row: 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 @@ -588,4 +680,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/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts index ed6c7ee0b..88a4762dc 100644 --- a/apps/frontend/__tests__/unit/utils/credits.spec.ts +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -1,4 +1,41 @@ -import { isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes, getBatchStatus } from '../../../src/utils/credits' +import { isMibOverCap, isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes, getBatchStatus } from '../../../src/utils/credits' + +// --------------------------------------------------------------------------- +// isMibOverCap (shared helper used by both package and custom-amount flows) +// --------------------------------------------------------------------------- + +describe('isMibOverCap', () => { + it('returns false when maxPurchasableBytes is null', () => { + expect(isMibOverCap(100, null)).toBe(false) + }) + + it('returns false when mib is 0', () => { + expect(isMibOverCap(0, BigInt(1024 * 1024))).toBe(false) + }) + + it('returns false when mib is negative', () => { + expect(isMibOverCap(-5, BigInt(1024 * 1024))).toBe(false) + }) + + it('returns true when maxPurchasableBytes is 0n', () => { + expect(isMibOverCap(1, 0n)).toBe(true) + }) + + it('returns false when requested bytes fit within the cap', () => { + const cap = BigInt(10 * 1024 * 1024) + expect(isMibOverCap(5, cap)).toBe(false) + }) + + it('returns false when requested bytes exactly equal the cap', () => { + const cap = BigInt(10 * 1024 * 1024) + expect(isMibOverCap(10, cap)).toBe(false) + }) + + it('returns true when requested bytes exceed the cap by 1 byte', () => { + const cap = BigInt(10 * 1024 * 1024) - 1n + expect(isMibOverCap(10, cap)).toBe(true) + }) +}) // --------------------------------------------------------------------------- // isPackageOverCap @@ -14,7 +51,7 @@ describe('isPackageOverCap', () => { }) it('returns false when package fits within the remaining cap', () => { - // 10 MB package, 20 MB remaining cap + // 10 MiB package, 20 MiB remaining cap const maxBytes = BigInt(20 * 1024 * 1024) expect(isPackageOverCap(10, maxBytes)).toBe(false) }) @@ -25,7 +62,7 @@ describe('isPackageOverCap', () => { }) it('returns true when package exceeds the remaining cap by 1 byte', () => { - // cap is 1 byte short of 10 MB + // cap is 1 byte short of 10 MiB const maxBytes = BigInt(10 * 1024 * 1024) - 1n expect(isPackageOverCap(10, maxBytes)).toBe(true) }) @@ -34,14 +71,14 @@ describe('isPackageOverCap', () => { expect(isPackageOverCap(10, 0n)).toBe(true) }) - it('handles large enterprise package (1 GB)', () => { - // 500 MB remaining — 1 GB package should be over cap + it('handles large enterprise package (1 GiB)', () => { + // 500 MiB remaining — 1 GiB package should be over cap const maxBytes = BigInt(500 * 1024 * 1024) expect(isPackageOverCap(1024, maxBytes)).toBe(true) }) it('handles large enterprise package within generous cap', () => { - // 2 GB remaining — 1 GB package should fit + // 2 GiB remaining — 1 GiB package should fit const maxBytes = BigInt(2 * 1024 * 1024 * 1024) expect(isPackageOverCap(1024, maxBytes)).toBe(false) }) @@ -112,28 +149,28 @@ describe('sumExpiringUploadBytes', () => { }) it('returns the correct sum for a single batch', () => { - const batches = [{ uploadBytesRemaining: '1048576' }] // 1 MB + const batches = [{ uploadBytesRemaining: '1048576' }] // 1 MiB expect(sumExpiringUploadBytes(batches)).toBe(1048576n) }) it('sums multiple batches correctly', () => { const batches = [ - { uploadBytesRemaining: '1048576' }, // 1 MB - { uploadBytesRemaining: '2097152' }, // 2 MB - { uploadBytesRemaining: '5242880' }, // 5 MB + { uploadBytesRemaining: '1048576' }, // 1 MiB + { uploadBytesRemaining: '2097152' }, // 2 MiB + { uploadBytesRemaining: '5242880' }, // 5 MiB ] - expect(sumExpiringUploadBytes(batches)).toBe(8388608n) // 8 MB total + expect(sumExpiringUploadBytes(batches)).toBe(8388608n) // 8 MiB total }) it('handles large byte values without overflow', () => { - // 1 GB each, 3 batches → 3 GB total - const oneMiB = BigInt(1024 * 1024 * 1024) + // 1 GiB each, 3 batches → 3 GiB total + const oneGiB = BigInt(1024 * 1024 * 1024) const batches = [ - { uploadBytesRemaining: oneMiB.toString() }, - { uploadBytesRemaining: oneMiB.toString() }, - { uploadBytesRemaining: oneMiB.toString() }, + { uploadBytesRemaining: oneGiB.toString() }, + { uploadBytesRemaining: oneGiB.toString() }, + { uploadBytesRemaining: oneGiB.toString() }, ] - expect(sumExpiringUploadBytes(batches)).toBe(oneMiB * 3n) + expect(sumExpiringUploadBytes(batches)).toBe(oneGiB * 3n) }) }) diff --git a/apps/frontend/__tests__/unit/utils/purchaseCredits.spec.ts b/apps/frontend/__tests__/unit/utils/purchaseCredits.spec.ts new file mode 100644 index 000000000..208619c4f --- /dev/null +++ b/apps/frontend/__tests__/unit/utils/purchaseCredits.spec.ts @@ -0,0 +1,273 @@ +import { + UNITS, + MIB_PER_UNIT, + bestUnit, + mibToDisplay, + sanitizeAmountInput, + inputToMib, + isCustomAmountOverCap, +} from '../../../src/utils/purchaseCredits'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +describe('UNITS', () => { + it('contains exactly MB, GB, TB in that order', () => { + expect(UNITS).toEqual(['MB', 'GB', 'TB']); + }); +}); + +describe('MIB_PER_UNIT', () => { + it('MB maps to 1 MiB (binary)', () => { + expect(MIB_PER_UNIT.MB).toBe(1); + }); + + it('GB maps to 1024 MiB (binary — same as 1 GiB)', () => { + expect(MIB_PER_UNIT.GB).toBe(1024); + }); + + it('TB maps to 1,048,576 MiB (1024 * 1024 — same as 1 TiB)', () => { + expect(MIB_PER_UNIT.TB).toBe(1024 * 1024); + }); +}); + +// --------------------------------------------------------------------------- +// bestUnit +// --------------------------------------------------------------------------- + +describe('bestUnit', () => { + it('returns MB for 0', () => { + expect(bestUnit(0)).toBe('MB'); + }); + + it('returns MB for negative values', () => { + expect(bestUnit(-100)).toBe('MB'); + }); + + it('returns MB for values below 1024 MiB', () => { + expect(bestUnit(1)).toBe('MB'); + expect(bestUnit(512)).toBe('MB'); + expect(bestUnit(1023)).toBe('MB'); + }); + + it('returns GB for exactly 1024 MiB (1 GB)', () => { + expect(bestUnit(1024)).toBe('GB'); + }); + + it('returns GB for values in the GB range', () => { + expect(bestUnit(1025)).toBe('GB'); + expect(bestUnit(512 * 1024)).toBe('GB'); // 512 GB + expect(bestUnit(1024 * 1024 - 1)).toBe('GB'); // just under 1 TB + }); + + it('returns TB for exactly 1024 GB (1 TB = 1,048,576 MiB)', () => { + expect(bestUnit(1024 * 1024)).toBe('TB'); + }); + + it('returns TB for values above 1 TB', () => { + expect(bestUnit(2 * 1024 * 1024)).toBe('TB'); + }); +}); + +// --------------------------------------------------------------------------- +// mibToDisplay +// --------------------------------------------------------------------------- + +describe('mibToDisplay', () => { + it('returns empty string for 0', () => { + expect(mibToDisplay(0, 'MB')).toBe(''); + }); + + it('returns empty string for negative values', () => { + expect(mibToDisplay(-1, 'GB')).toBe(''); + }); + + it('converts MiB to MB (1:1)', () => { + expect(mibToDisplay(10, 'MB')).toBe('10'); + expect(mibToDisplay(100, 'MB')).toBe('100'); + }); + + it('converts MiB to GB (÷1024)', () => { + expect(mibToDisplay(1024, 'GB')).toBe('1'); + expect(mibToDisplay(2048, 'GB')).toBe('2'); + expect(mibToDisplay(512, 'GB')).toBe('0.5'); + }); + + it('converts MiB to TB (÷1048576)', () => { + expect(mibToDisplay(1024 * 1024, 'TB')).toBe('1'); + expect(mibToDisplay(512 * 1024, 'TB')).toBe('0.5'); + }); + + it('trims to 4 significant figures, no trailing zeros', () => { + // 1 MiB / 1024 ≈ 0.0009766 GB → 4 sig figs → "0.0009766" + expect(mibToDisplay(1, 'GB')).toBe('0.0009766'); + // 1023 MiB in GB = 0.9990234... → 4 sig figs → "0.999" + expect(mibToDisplay(1023, 'GB')).toBe('0.999'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeAmountInput +// --------------------------------------------------------------------------- + +describe('sanitizeAmountInput', () => { + it('passes through a simple integer string', () => { + expect(sanitizeAmountInput('100')).toBe('100'); + }); + + it('passes through a decimal string', () => { + expect(sanitizeAmountInput('1.5')).toBe('1.5'); + }); + + it('strips alphabetic characters', () => { + expect(sanitizeAmountInput('1e5')).toBe('15'); + expect(sanitizeAmountInput('100abc')).toBe('100'); + }); + + it('strips negative sign (caller treats result as 0 / invalid)', () => { + // The negative sign is stripped; inputToMib returns 0 for non-positive + // values, so "-100" → "100" → still produces a valid positive size. + expect(sanitizeAmountInput('-100')).toBe('100'); + }); + + it('removes extra decimal points', () => { + expect(sanitizeAmountInput('1.2.3')).toBe('1.2'); + // "..5" — regex captures up to and including the first dot, second dot + // stops the digit run, so result is "." (treated as 0 by the UI). + expect(sanitizeAmountInput('..5')).toBe('.'); + }); + + it('returns empty string for fully non-numeric input', () => { + expect(sanitizeAmountInput('abc')).toBe(''); + }); + + it('allows a leading decimal point (user is typing "0.5")', () => { + expect(sanitizeAmountInput('.5')).toBe('.5'); + }); + + it('handles empty string', () => { + expect(sanitizeAmountInput('')).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// inputToMib +// --------------------------------------------------------------------------- + +describe('inputToMib', () => { + it('returns 0 for empty string', () => { + expect(inputToMib('', 'MB')).toBe(0); + }); + + it('returns 0 for "0"', () => { + expect(inputToMib('0', 'GB')).toBe(0); + }); + + it('returns 0 for negative values', () => { + expect(inputToMib('-5', 'MB')).toBe(0); + }); + + it('returns 0 for non-numeric string', () => { + expect(inputToMib('abc', 'MB')).toBe(0); + }); + + it('converts MB value (1:1)', () => { + expect(inputToMib('10', 'MB')).toBe(10); + expect(inputToMib('512', 'MB')).toBe(512); + }); + + it('converts GB to MiB (×1024)', () => { + expect(inputToMib('1', 'GB')).toBe(1024); + expect(inputToMib('2', 'GB')).toBe(2048); + expect(inputToMib('0.5', 'GB')).toBe(512); + }); + + it('converts TB to MiB (×1048576)', () => { + expect(inputToMib('1', 'TB')).toBe(1024 * 1024); + expect(inputToMib('0.5', 'TB')).toBe(512 * 1024); + }); + + it('rounds fractional MiB results to the nearest integer', () => { + // 0.1 GB = 102.4 MiB → rounds to 102 + expect(inputToMib('0.1', 'GB')).toBe(102); + }); + + it('handles large TB values without overflow', () => { + // 100 TB = 104,857,600 MiB — well within JS safe integer range + expect(inputToMib('100', 'TB')).toBe(100 * 1024 * 1024); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip unit conversion +// --------------------------------------------------------------------------- + +describe('unit conversion round-trip', () => { + it('MB → GB → MB preserves the MiB count (within rounding)', () => { + const originalMib = 512; + const gbDisplay = mibToDisplay(originalMib, 'GB'); // "0.5" + const roundTrip = inputToMib(gbDisplay, 'GB'); + expect(roundTrip).toBe(originalMib); + }); + + it('GB → TB → GB preserves the MiB count for round values', () => { + const originalMib = 2 * 1024; // 2 GB + const tbDisplay = mibToDisplay(originalMib, 'TB'); + const roundTrip = inputToMib(tbDisplay, 'TB'); + // Allow ±1 MiB due to toPrecision(4) rounding + expect(Math.abs(roundTrip - originalMib)).toBeLessThanOrEqual(1); + }); + + it('1 TB round-trips exactly', () => { + const originalMib = 1024 * 1024; + const tbDisplay = mibToDisplay(originalMib, 'TB'); // "1" + expect(inputToMib(tbDisplay, 'TB')).toBe(originalMib); + }); +}); + +// --------------------------------------------------------------------------- +// isCustomAmountOverCap +// --------------------------------------------------------------------------- + +describe('isCustomAmountOverCap', () => { + it('returns false when maxPurchasableBytes is null', () => { + expect(isCustomAmountOverCap(100, null)).toBe(false); + }); + + it('returns true when maxPurchasableBytes is 0n (cap fully exhausted)', () => { + expect(isCustomAmountOverCap(1, BigInt(0))).toBe(true); + }); + + it('returns false when requestedMib is 0', () => { + expect(isCustomAmountOverCap(0, BigInt(1024 * 1024))).toBe(false); + }); + + it('returns false when requested bytes fit within the cap', () => { + const cap = BigInt(10 * 1024 * 1024); // 10 MB cap + expect(isCustomAmountOverCap(5, cap)).toBe(false); + }); + + it('returns false when requested bytes exactly equal the cap', () => { + const cap = BigInt(10 * 1024 * 1024); // exactly 10 MB + expect(isCustomAmountOverCap(10, cap)).toBe(false); + }); + + it('returns true when requested bytes exceed the cap by 1 byte', () => { + const cap = BigInt(10 * 1024 * 1024) - BigInt(1); // 1 byte short of 10 MB + expect(isCustomAmountOverCap(10, cap)).toBe(true); + }); + + it('returns true when requested is much larger than the cap', () => { + const cap = BigInt(1024 * 1024); // 1 MB cap + expect(isCustomAmountOverCap(1024, cap)).toBe(true); // 1 GB requested + }); + + it('handles large TB-scale values correctly', () => { + // cap = 5 TB in bytes + const cap = BigInt(5) * BigInt(1024) * BigInt(1024) * BigInt(1024 * 1024); + const fiveTbMib = 5 * 1024 * 1024; + expect(isCustomAmountOverCap(fiveTbMib, cap)).toBe(false); + expect(isCustomAmountOverCap(fiveTbMib + 1, cap)).toBe(true); + }); +}); 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/molecules/AccountInformation/index.tsx b/apps/frontend/src/components/molecules/AccountInformation/index.tsx index 8166c1c04..dcaa92303 100644 --- a/apps/frontend/src/components/molecules/AccountInformation/index.tsx +++ b/apps/frontend/src/components/molecules/AccountInformation/index.tsx @@ -36,8 +36,14 @@ export const AccountInformation = ({ nextExpiryDate = null, creditHistoryHref, }: CreditLimitsProps) => { + // uploadPending is free-remaining only (purchased bytes stripped out by the + // caller). uploadUsed may be negative when the free quota is fully + // exhausted and the user is uploading against purchased credits. const uploadUsed = uploadLimit - uploadPending; + // Progress bar: clamp to [0, 100]. A negative uploadUsed (free quota + // over-extended by purchased credits) renders as 100%, which is correct — + // the free allocation is fully consumed. const uploadPercentage = Math.max( 0, Math.min(100, (uploadUsed / uploadLimit) * 100), @@ -45,14 +51,21 @@ export const AccountInformation = ({ const hasPurchasedCredits = purchasedBytesRemaining > 0; + // When the user has purchased credits, show total available (free + purchased) + // as the primary "left" figure. This is the number that governs whether an + // upload will succeed. When purchased credits are not in play, show only the + // free remaining so the label and progress bar tell the same story. + const totalAvailable = uploadPending + purchasedBytesRemaining; + const displayAvailable = hasPurchasedCredits ? totalAvailable : uploadPending; + return (
Upload usage
- {formatBytes(uploadPending, 2)} left + {formatBytes(displayAvailable, 2)} left - {formatBytes(uploadUsed, 2)}/{formatBytes(uploadLimit, 2)} + {formatBytes(Math.max(0, uploadUsed), 2)}/{formatBytes(uploadLimit, 2)}
diff --git a/apps/frontend/src/components/organisms/SideNavBar/index.tsx b/apps/frontend/src/components/organisms/SideNavBar/index.tsx index f4165a4e1..efef8ee09 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/index.tsx +++ b/apps/frontend/src/components/organisms/SideNavBar/index.tsx @@ -58,16 +58,22 @@ export const SideNavbar = ({ networkId }: SideNavbarProps) => { const hasBuyCreditsFeature = features.buyCredits && isLoggedIn && account?.model === AccountModel.OneOff; - // Purchased-credit fields — only derived when the buyCredits feature is - // active for this user. Both values default to "not shown" otherwise, - // which keeps the sidebar unchanged for Monthly, free-only OneOff, and - // any account whose operator has disabled the feature flag. + // creditSummary is always loaded for any logged-in user (see SessionEnsurer), + // so we can safely derive purchasedBytesRemaining from it regardless of the + // feature flag. This is needed to separate free vs. purchased bytes in the + // progress bar even when the buy-credits UI is hidden. const purchasedBytesRemaining = useMemo(() => { - if (!hasBuyCreditsFeature || !creditSummary) return 0; - // creditSummary.uploadBytesRemaining is a decimal-string bigint from the - // API. Max value is 100 GiB which is well within Number's safe range. + if (!creditSummary) return 0; + // uploadBytesRemaining is a decimal-string bigint from the API. + // Max safe value is 100 GiB which is well within Number's safe range. return Number(creditSummary.uploadBytesRemaining); - }, [hasBuyCreditsFeature, creditSummary]); + }, [creditSummary]); + + // account.pendingUploadCredits = freeRemaining + purchasedRemaining. + // Strip out the purchased portion so the progress bar and "used/limit" label + // only reflect the free allocation. Negative means the free quota is + // exhausted (user is relying entirely on purchased credits). + const freeRemaining = (account?.pendingUploadCredits ?? 0) - purchasedBytesRemaining; const nextExpiryDate = useMemo(() => { if (!hasBuyCreditsFeature || !creditSummary?.nextExpiryDate) return null; @@ -110,8 +116,8 @@ export const SideNavbar = ({ networkId }: SideNavbarProps) => { model={account?.model ?? AccountModel.OneOff} renewalDate={renewalDate} uploadLimit={account?.uploadLimit ?? 0} - uploadPending={account?.pendingUploadCredits ?? 0} - purchasedBytesRemaining={purchasedBytesRemaining} + uploadPending={freeRemaining} + purchasedBytesRemaining={hasBuyCreditsFeature ? purchasedBytesRemaining : 0} nextExpiryDate={nextExpiryDate} creditHistoryHref={creditHistoryHref} /> 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..7fbca2eed --- /dev/null +++ b/apps/frontend/src/components/views/AdminPanel/AdminUserCredits.tsx @@ -0,0 +1,267 @@ +'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'; +import { shannonsToAi3 } from '@autonomys/auto-utils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Format a raw shannons string (from the wire API) as a human-readable AI3 + * amount, e.g. "1.234567 AI3". Uses the canonical SDK converter which + * handles the 1e18 shannons-per-AI3 conversion with proper precision. + */ +const formatAI3Paid = (paymentAmount: string | null): string => { + if (!paymentAmount) return '—'; + try { + return `${shannonsToAi3(BigInt(paymentAmount), { trimTrailingZeros: true })} 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.refundedAt !== null).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.refundedAt !== null && ( + + + 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.refundedAt !== null ? ( + + {formatDate(batch.refundedAt)} + + ) : ( + + )} +
+
+ )} +
+ ); +}; diff --git a/apps/frontend/src/components/views/PurchaseCredits/CreditCurrentPrice.tsx b/apps/frontend/src/components/views/PurchaseCredits/CreditCurrentPrice.tsx index 76002c1b9..0318b9c6d 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/CreditCurrentPrice.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/CreditCurrentPrice.tsx @@ -12,7 +12,7 @@ export const CreditCurrentPrice = () => {
Current Price
{typeof shannonsPerByte === 'number' - ? `${formatCreditsAsAi3(MiB).toFixed(2)} AI3 per MiB` + ? `${formatCreditsAsAi3(MiB).toFixed(2)} AI3 per MB` : '—'}
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/Step2_ConfirmPurchase.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx index d49bb62c0..106cd39cc 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx @@ -3,16 +3,27 @@ import { Button } from '@auto-drive/ui'; import { InfoRow } from '../atoms/InfoRow'; import { Section } from '../atoms/Section'; -import { useCallback, useMemo } from 'react'; -import { Zap } from 'lucide-react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; +import { Zap, AlertTriangle, Info } from 'lucide-react'; import { CreditCurrentPrice } from '../CreditCurrentPrice'; import { GoBackButton } from '../../../atoms/GoBackButton'; import { usePrices } from '../../../../hooks/usePrices'; -import { - formatBytes, - truncateNumberWithDecimals, -} from '../../../../utils/number'; +import { formatBytes } from '../../../../utils/number'; import { useUserStore } from '../../../../globalStates/user'; +import { + UNITS, + type Unit, + MIB_PER_UNIT, + bestUnit, + mibToDisplay, + sanitizeAmountInput, + inputToMib, + isCustomAmountOverCap, +} from '../../../../utils/purchaseCredits'; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export const PurchaseStep2ConnectWallet = ({ onNext, @@ -25,76 +36,133 @@ export const PurchaseStep2ConnectWallet = ({ context: Record; onContextChange: (data: Record) => void; }) => { - const { - formatCreditsInMbAsUsd, - formatAi3AsCreditsInMb, - formatCreditsInMbAsAi3, - } = usePrices(); + const { formatCreditsInMbAsUsd, formatCreditsInMbAsAi3 } = usePrices(); const isCustom = String(context.packageId ?? 'custom') === 'custom'; - // creditSummary.uploadBytesRemaining is the user's current purchased-credit - // pool (a decimal bigint string from the API). We display this — not the - // free-tier pendingUploadCredits — because Step 2 is in the purchase flow - // and the user is buying more purchased credits. - // Safely defaults to 0 while the summary is still loading or when the user - // has no purchased credits yet. const currentPurchasedBytes = useUserStore((s) => s.creditSummary ? Number(s.creditSummary.uploadBytesRemaining) : 0, ); + const maxPurchasableBytes = useUserStore((s) => + s.creditSummary ? BigInt(s.creditSummary.maxPurchasableBytes) : null, + ); + + // ------------------------------------------------------------------------- + // Derive fixed-package title & size + // ------------------------------------------------------------------------- + const { title, sizeMB } = useMemo(() => { const id = String(context.packageId ?? 'custom'); switch (id) { case 'starter': - return { - title: 'Starter', - sizeMB: 10, - }; + return { title: 'Starter', sizeMB: 10 }; case 'pro': - return { - title: 'Professional Package', - sizeMB: 100, - }; + return { title: 'Professional Package', sizeMB: 100 }; case 'ent': - return { - title: 'Enterprise', - sizeMB: 1024, - }; + return { title: 'Enterprise', sizeMB: 1024 }; default: - return { - title: 'Custom Amount', - sizeMB: (context.sizeMB as number) ?? 0, - }; + return { title: 'Custom Amount', sizeMB: (context.sizeMB as number) ?? 0 }; } }, [context.packageId, context.sizeMB]); - const onChangeMb = useCallback( - (value: string) => { - const mb = Math.max(0, Number(value) || 0); - onContextChange({ sizeMB: mb }); + // ------------------------------------------------------------------------- + // Custom-amount local state: unit selector + raw string input value + // ------------------------------------------------------------------------- + + const [unit, setUnit] = useState(() => bestUnit(sizeMB || 1)); + const [inputValue, setInputValue] = useState(() => + mibToDisplay(sizeMB, bestUnit(sizeMB || 1)), + ); + + // When context sizeMB changes externally (e.g. navigating back), re-sync + // the display value only if the input is empty (avoids clobbering typing). + useEffect(() => { + if (isCustom && sizeMB > 0 && inputValue === '') { + const u = bestUnit(sizeMB); + setUnit(u); + setInputValue(mibToDisplay(sizeMB, u)); + } + }, [isCustom, sizeMB, inputValue]); + + /** MiB value derived from the current input + unit. */ + const customSizeMib = useMemo( + () => inputToMib(inputValue, unit), + [inputValue, unit], + ); + + /** The effective MiB value for price calculations. */ + const effectiveMib = isCustom ? customSizeMib : sizeMB; + + // ------------------------------------------------------------------------- + // Cap validation + // ------------------------------------------------------------------------- + + const capExceeded = useMemo( + () => + isCustom && isCustomAmountOverCap(effectiveMib, maxPurchasableBytes ?? null), + [isCustom, maxPurchasableBytes, effectiveMib], + ); + + const maxPurchasableMib = useMemo(() => { + if (maxPurchasableBytes === null) return null; + return Number(maxPurchasableBytes / BigInt(1024 * 1024)); + }, [maxPurchasableBytes]); + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + const handleInputChange = useCallback( + (raw: string) => { + // Allow only digits and a single decimal point — prevents leading zeros, + // scientific notation ("1e5"), and negative values. + const sanitised = sanitizeAmountInput(raw); + setInputValue(sanitised); + onContextChange({ sizeMB: inputToMib(sanitised, unit) }); }, - [onContextChange], + [unit, onContextChange], ); - const onChangeAi3 = useCallback( - (value: string) => { - const mb = truncateNumberWithDecimals( - formatAi3AsCreditsInMb(Number(value)), - 2, - ); - onContextChange({ sizeMB: mb }); + const handleUnitChange = useCallback( + (newUnit: Unit) => { + // Convert the current MiB value into the new unit to keep the + // displayed number consistent with the underlying purchase size. + const currentMib = parseFloat(inputValue) * MIB_PER_UNIT[unit]; + const newDisplay = + isFinite(currentMib) && currentMib > 0 ? mibToDisplay(currentMib, newUnit) : ''; + setUnit(newUnit); + setInputValue(newDisplay); + // Keep context.sizeMB in sync so Step 3 sees the correct value + // even if the user navigates forward without re-typing. + onContextChange({ sizeMB: inputToMib(newDisplay, newUnit) }); }, - [formatAi3AsCreditsInMb, onContextChange], + [inputValue, unit, onContextChange], ); + // ------------------------------------------------------------------------- + // Derived price display values + // ------------------------------------------------------------------------- + + const ai3Amount = formatCreditsInMbAsAi3(effectiveMib); + const usdAmount = formatCreditsInMbAsUsd(effectiveMib); + const afterPurchaseBytes = currentPurchasedBytes + effectiveMib * 1024 * 1024; + + const canConfirm = effectiveMib > 0 && !capExceeded; + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + return (
+
+ {/* Left: Order details */}
} > + {/* Summary banner */}
{title}
@@ -113,53 +182,101 @@ export const PurchaseStep2ConnectWallet = ({
-
{sizeMB}MiB
+
+ {formatBytes(effectiveMib * 1024 * 1024, 2)} +
Storage
- +
+ Storage Amount +
+ + {/* Unit toggle + numeric input */} +
onChangeMb(e.target.value)} + type='text' + inputMode='decimal' + placeholder='0' + className='w-40 rounded-md border px-3 py-2 text-xl font-semibold tabular-nums focus:outline-none focus:ring-2 focus:ring-primary dark:bg-gray-800' + value={inputValue} + onChange={(e) => handleInputChange(e.target.value)} /> - ) : ( - {sizeMB}MiB - ) - } + {/* Unit selector */} +
+ {UNITS.map((u) => ( + + ))} +
+ {/* Binary-units info bubble */} +
+ +
+

About these units

+

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

+
    +
  • 1 GB = 1,024 MiB (not 1,000 MB)
  • +
  • 1 TB = 1,024 GiB (not 1,000 GB)
  • +
+
+
+
+ + {/* 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 +286,7 @@ export const PurchaseStep2ConnectWallet = ({ label='Total' value={ - {formatCreditsInMbAsAi3(Number(sizeMB)).toFixed(2)} AI3 + {ai3Amount > 0 ? `${ai3Amount.toFixed(2)} AI3` : '—'} } accent @@ -179,6 +296,7 @@ export const PurchaseStep2ConnectWallet = ({
+ {/* Right: Payment summary */}
@@ -191,10 +309,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 +322,9 @@ export const PurchaseStep2ConnectWallet = ({ Back