Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
908b496
feat(ImprovePwAI3UX): admin per-user credit detail page with refund s…
EmilFattakhov Apr 1, 2026
23d5a2c
refactor: drop redundant refunded boolean, use refunded_at IS NOT NULL
EmilFattakhov Apr 1, 2026
e10fc96
chore: merge origin/main into ImprovePwAI3UX
EmilFattakhov Apr 1, 2026
2f2e810
fix(sidebar): correct upload usage display when purchased credits are…
EmilFattakhov Apr 1, 2026
1c4925e
feat(purchase): redesign custom amount input with unit toggle and val…
EmilFattakhov Apr 1, 2026
084ad90
refactor(purchase): extract unit-toggle helpers and fix lint warnings
EmilFattakhov Apr 2, 2026
ed9d39f
fix(tests): update PaymentManager spec to assert fromAddress in markI…
EmilFattakhov Apr 2, 2026
af85915
fix(tests): add missing refundedAt field to credits spec fixture
EmilFattakhov Apr 2, 2026
7727ecd
fix(credits): use NotFoundError instead of plain Error in refundBatch
EmilFattakhov Apr 2, 2026
35aaee0
fix(credits): make markAsRefunded truly idempotent by guarding on ref…
EmilFattakhov Apr 2, 2026
3d25ea9
remove unused computePaymentShannons (duplicates usePrices inline for…
EmilFattakhov Apr 2, 2026
d96d2bb
fix(credits): use strict null check for BigInt cap guards
EmilFattakhov Apr 2, 2026
ead9cd6
refactor(credits): extract shared isMibOverCap helper to eliminate du…
EmilFattakhov Apr 2, 2026
4554cb4
Apply lint fix
EmilFattakhov Apr 2, 2026
377c55d
fix(frontend): use familiar MB/GB/TB labels and SDK shannonsToAi3 in …
EmilFattakhov Apr 2, 2026
3f24725
feat(frontend): add binary-units info tooltip to custom amount selector
EmilFattakhov Apr 2, 2026
37bf0af
Apply suggestions from code review
EmilFattakhov Apr 2, 2026
268e8b0
Merge branch 'main' into ImprovePwAI3UX
EmilFattakhov Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/backend/__tests__/unit/PaymentManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
})
})

Expand Down
1 change: 1 addition & 0 deletions apps/backend/__tests__/unit/useCases/credits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const makeCreditRow = (
purchasedAt: now,
expiresAt: FUTURE_EXPIRY,
expired: false,
refundedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
Expand Down
53 changes: 53 additions & 0 deletions apps/backend/migrations/20260401000000-intent-from-address.js
Original file line number Diff line number Diff line change
@@ -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,
}
53 changes: 53 additions & 0 deletions apps/backend/migrations/20260401000001-purchased-credits-refund.js
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE intents DROP COLUMN IF EXISTS from_address;
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE purchased_credits
DROP COLUMN IF EXISTS refunded_at;
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 71 additions & 0 deletions apps/backend/src/app/controllers/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
59 changes: 58 additions & 1 deletion apps/backend/src/core/users/credits.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<Result<AdminUserCreditBatchRow[], ForbiddenError>> => {
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<Result<void, ForbiddenError | NotFoundError>> => {
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,
}
3 changes: 3 additions & 0 deletions apps/backend/src/core/users/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -182,6 +184,7 @@ const markIntentAsConfirmed = async ({
...intent,
status: IntentStatus.CONFIRMED,
paymentAmount,
fromAddress: fromAddress ?? intent.fromAddress,
}),
)
}
Expand Down
8 changes: 6 additions & 2 deletions apps/backend/src/infrastructure/repositories/users/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] => {
Expand All @@ -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,
}))
}

Expand Down Expand Up @@ -59,8 +61,9 @@ const updateIntent = async (intent: Intent): Promise<Intent> => {
const result = await db.query<DBIntent>(
`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,
Expand All @@ -69,6 +72,7 @@ const updateIntent = async (intent: Intent): Promise<Intent> => {
intent.paymentAmount?.toString() ?? null,
intent.shannonsPerByte,
intent.expiresAt ?? null,
intent.fromAddress ?? null,
intent.id,
],
)
Expand Down
Loading
Loading