From 331100d0188cc5d666e2ca77bed5674b0ebbc765 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 6 Mar 2026 19:19:46 +0000 Subject: [PATCH 01/78] feat(credits): add Credit API endpoints (Task 2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes four authenticated REST endpoints under /credits, gated by the buyCredits feature flag (same middleware as /intents): GET /credits/summary — user's remaining bytes, next expiry, canPurchase flag, maxPurchasableBytes, and googleVerified status GET /credits/batches — full purchase history (incl. expired rows) GET /credits/batches/expiring — active rows expiring within 30 days GET /credits/economics — admin-only system-wide expiry stats Key design decisions: - All responses are scoped to the authenticated user via JWT (no accountId in the URL); /economics is further gated by UserRole.Admin. - canPurchase / maxPurchasableBytes use the larger of upload vs download remaining as the binding constraint, since each purchase grows both equally. - BigInt fields are serialised as strings on the wire (matches intents pattern). - Added getExpiringCreditsByAccountId(accountId, withinDays) to the purchasedCredits repository — per-user variant of the existing system-wide getExpiringCredits, needed for the /batches/expiring endpoint. - 14 unit tests covering balance calculations, asymmetric byte scenarios, cap-boundary edge cases, admin/non-admin gating, and 30-day window. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/unit/useCases/credits.spec.ts | 355 ++++++++++++++++++ apps/backend/src/app/apis/frontend.ts | 2 + apps/backend/src/app/controllers/credits.ts | 150 ++++++++ apps/backend/src/core/users/credits.ts | 148 ++++++++ .../repositories/users/purchasedCredits.ts | 29 +- 5 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 apps/backend/__tests__/unit/useCases/credits.spec.ts create mode 100644 apps/backend/src/app/controllers/credits.ts create mode 100644 apps/backend/src/core/users/credits.ts diff --git a/apps/backend/__tests__/unit/useCases/credits.spec.ts b/apps/backend/__tests__/unit/useCases/credits.spec.ts new file mode 100644 index 000000000..74f7e0ea0 --- /dev/null +++ b/apps/backend/__tests__/unit/useCases/credits.spec.ts @@ -0,0 +1,355 @@ +import { jest } from '@jest/globals' +import { CreditsUseCases } from '../../../src/core/users/credits.js' +import { purchasedCreditsRepository } from '../../../src/infrastructure/repositories/users/purchasedCredits.js' +import { AccountsUseCases } from '../../../src/core/users/accounts.js' +import { ForbiddenError } from '../../../src/errors/index.js' +import { + type Account, + type PurchasedCredit, + type User, + type UserWithOrganization, + UserRole, +} from '@auto-drive/models' +import { config } from '../../../src/config.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Test fixtures +// ───────────────────────────────────────────────────────────────────────────── + +const now = new Date() + +const baseUser: UserWithOrganization = { + id: 'user-id', + publicId: 'pub-1', + walletAddress: '0xabc', + createdAt: now, + updatedAt: now, + authProvider: 'google', + oauthProvider: 'google', + oauthUsername: 'test@gmail.com', + organizationId: 'org-1', + role: UserRole.User, +} as unknown as UserWithOrganization + +const adminUser: User = { + ...baseUser, + role: UserRole.Admin, +} as unknown as User + +const nonAdminUser: User = { + ...baseUser, + role: UserRole.User, +} as unknown as User + +const mockAccount: Account = { + id: 'account-id', + organizationId: 'org-1', + model: 'monthly', + uploadLimit: 100, + downloadLimit: 100, +} as unknown as Account + +const FUTURE_EXPIRY = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) +const SOON_EXPIRY = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days + +const makeCreditRow = ( + overrides: Partial = {}, +): PurchasedCredit => ({ + id: 'credit-1', + accountId: 'account-id', + intentId: 'intent-1', + uploadBytesOriginal: BigInt(1024 * 1024 * 1024), // 1 GiB + uploadBytesRemaining: BigInt(1024 * 1024 * 1024), + downloadBytesOriginal: BigInt(1024 * 1024 * 1024), + downloadBytesRemaining: BigInt(1024 * 1024 * 1024), + purchasedAt: now, + expiresAt: FUTURE_EXPIRY, + expired: false, + createdAt: now, + updatedAt: now, + ...overrides, +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Test suite +// ───────────────────────────────────────────────────────────────────────────── + +describe('CreditsUseCases', () => { + beforeEach(() => { + jest.clearAllMocks() + jest + .spyOn(AccountsUseCases, 'getOrCreateAccount') + .mockResolvedValue(mockAccount) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + // ────────────────────────────────────────────────────────────────────────── + // getSummary + // ────────────────────────────────────────────────────────────────────────── + + describe('getSummary', () => { + it('returns zeros and canPurchase=true when no credits exist', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.uploadBytesRemaining).toBe(0n) + expect(summary.downloadBytesRemaining).toBe(0n) + expect(summary.nextExpiryDate).toBeNull() + expect(summary.batchCount).toBe(0) + expect(summary.canPurchase).toBe(true) + expect(summary.maxPurchasableBytes).toBe(config.credits.maxBytesPerUser) + }) + + it('returns googleVerified=true for Google-authed user', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + expect(summary.googleVerified).toBe(true) + }) + + it('returns googleVerified=false for non-Google user', async () => { + const githubUser: UserWithOrganization = { + ...baseUser, + oauthProvider: 'github', + } as unknown as UserWithOrganization + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(githubUser) + expect(summary.googleVerified).toBe(false) + }) + + it('computes maxPurchasableBytes using the larger of upload/download remaining', async () => { + // Upload has 80 GiB remaining, download has 50 GiB + // Binding constraint is upload (80 GiB), so room = cap - 80 GiB + const uploadRemaining = BigInt(80 * 1024 ** 3) + const downloadRemaining = BigInt(50 * 1024 ** 3) + const cap = config.credits.maxBytesPerUser // 100 GiB + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: uploadRemaining, + downloadBytesRemaining: downloadRemaining, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 2, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.maxPurchasableBytes).toBe(cap - uploadRemaining) + expect(summary.canPurchase).toBe(true) + }) + + it('returns canPurchase=false and maxPurchasableBytes=0n when at or over cap', async () => { + const cap = config.credits.maxBytesPerUser + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: cap, + downloadBytesRemaining: cap, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 1, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.canPurchase).toBe(false) + expect(summary.maxPurchasableBytes).toBe(0n) + }) + + it('handles asymmetric remaining bytes (download higher than upload)', async () => { + const uploadRemaining = BigInt(30 * 1024 ** 3) + const downloadRemaining = BigInt(70 * 1024 ** 3) // download is binding + const cap = config.credits.maxBytesPerUser + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: uploadRemaining, + downloadBytesRemaining: downloadRemaining, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 3, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + // Download (70 GiB) is the binding constraint + expect(summary.maxPurchasableBytes).toBe(cap - downloadRemaining) + expect(summary.canPurchase).toBe(true) + }) + + it('populates batchCount and nextExpiryDate from repository', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: BigInt(1024), + downloadBytesRemaining: BigInt(1024), + nextExpiryDate: SOON_EXPIRY, + activeRowCount: 5, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.batchCount).toBe(5) + expect(summary.nextExpiryDate).toEqual(SOON_EXPIRY) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getBatches + // ────────────────────────────────────────────────────────────────────────── + + describe('getBatches', () => { + it('returns the full purchase history from repository', async () => { + const credits = [makeCreditRow(), makeCreditRow({ id: 'credit-2' })] + jest + .spyOn(purchasedCreditsRepository, 'getByAccountId') + .mockResolvedValue(credits) + + const result = await CreditsUseCases.getBatches(baseUser) + + expect(result).toEqual(credits) + expect( + purchasedCreditsRepository.getByAccountId, + ).toHaveBeenCalledWith(mockAccount.id) + }) + + it('returns an empty array when no purchases exist', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getByAccountId') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getBatches(baseUser) + expect(result).toEqual([]) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getExpiringBatches + // ────────────────────────────────────────────────────────────────────────── + + describe('getExpiringBatches', () => { + it('returns rows expiring soon via per-account repository method', async () => { + const expiring = [makeCreditRow({ expiresAt: SOON_EXPIRY })] + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsByAccountId') + .mockResolvedValue(expiring) + + const result = await CreditsUseCases.getExpiringBatches(baseUser) + + expect(result).toEqual(expiring) + expect( + purchasedCreditsRepository.getExpiringCreditsByAccountId, + ).toHaveBeenCalledWith(mockAccount.id, 30) + }) + + it('returns empty array when nothing is expiring', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsByAccountId') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getExpiringBatches(baseUser) + expect(result).toEqual([]) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getEconomics + // ────────────────────────────────────────────────────────────────────────── + + describe('getEconomics', () => { + it('returns 403 ForbiddenError for non-admin user', async () => { + const getExpiringSpy = jest + .spyOn(purchasedCreditsRepository, 'getExpiringCredits') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getEconomics(nonAdminUser) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + expect(getExpiringSpy).not.toHaveBeenCalled() + }) + + it('returns aggregated economics for admin user', async () => { + const expiringRows = [ + makeCreditRow({ + uploadBytesRemaining: BigInt(2 * 1024 ** 3), + downloadBytesRemaining: BigInt(3 * 1024 ** 3), + }), + makeCreditRow({ + id: 'credit-2', + uploadBytesRemaining: BigInt(1024 ** 3), + downloadBytesRemaining: BigInt(2 * 1024 ** 3), + }), + ] + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCredits') + .mockResolvedValue(expiringRows) + + const result = await CreditsUseCases.getEconomics(adminUser) + + expect(result.isOk()).toBe(true) + const economics = result._unsafeUnwrap() + expect(economics.totalExpiringWithin30Days).toBe(2) + expect(economics.totalExpiringUploadBytes).toBe( + BigInt(2 * 1024 ** 3) + BigInt(1024 ** 3), + ) + expect(economics.totalExpiringDownloadBytes).toBe( + BigInt(3 * 1024 ** 3) + BigInt(2 * 1024 ** 3), + ) + }) + + it('returns zeros when no credits are expiring soon', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCredits') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getEconomics(adminUser) + + expect(result.isOk()).toBe(true) + const economics = result._unsafeUnwrap() + expect(economics.totalExpiringWithin30Days).toBe(0) + expect(economics.totalExpiringUploadBytes).toBe(0n) + expect(economics.totalExpiringDownloadBytes).toBe(0n) + }) + + it('queries within 30 days window', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCredits') + .mockResolvedValue([]) + + await CreditsUseCases.getEconomics(adminUser) + + expect( + purchasedCreditsRepository.getExpiringCredits, + ).toHaveBeenCalledWith(30) + }) + }) +}) diff --git a/apps/backend/src/app/apis/frontend.ts b/apps/backend/src/app/apis/frontend.ts index cdd88b4c2..5dcb67432 100644 --- a/apps/backend/src/app/apis/frontend.ts +++ b/apps/backend/src/app/apis/frontend.ts @@ -10,6 +10,7 @@ import { config } from '../../config.js' import { createLogger } from '../../infrastructure/drivers/logger.js' import { docsController } from '../controllers/docs.js' import { intentsController } from '../controllers/intents.js' +import { creditsController } from '../controllers/credits.js' import { featuresController } from '../controllers/features.js' import { featureFlagMiddleware } from '../../core/featureFlags/express.js' import { IntentsUseCases } from '../../core/users/intents.js' @@ -72,6 +73,7 @@ const createServer = async () => { }), ) app.use('/intents', featureFlagMiddleware('buyCredits'), intentsController) + app.use('/credits', featureFlagMiddleware('buyCredits'), creditsController) app.use('/features', featuresController) app.use('/docs', docsController) diff --git a/apps/backend/src/app/controllers/credits.ts b/apps/backend/src/app/controllers/credits.ts new file mode 100644 index 000000000..fcd26c70c --- /dev/null +++ b/apps/backend/src/app/controllers/credits.ts @@ -0,0 +1,150 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { CreditsUseCases } from '../../core/users/credits.js' +import { + handleInternalError, + handleInternalErrorResult, +} from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' +import { PurchasedCredit } from '@auto-drive/models' + +export const creditsController = Router() + +// --------------------------------------------------------------------------- +// Serialisation helpers +// --------------------------------------------------------------------------- + +// PurchasedCredit rows contain bigint fields that cannot be JSON-serialised +// directly. We convert each bigint to a string so the wire format is stable +// and the frontend can parse them with BigInt() or a numeric library. +const serializeCredit = (credit: PurchasedCredit) => ({ + ...credit, + uploadBytesOriginal: credit.uploadBytesOriginal.toString(), + uploadBytesRemaining: credit.uploadBytesRemaining.toString(), + downloadBytesOriginal: credit.downloadBytesOriginal.toString(), + downloadBytesRemaining: credit.downloadBytesRemaining.toString(), +}) + +// --------------------------------------------------------------------------- +// GET /credits/summary +// Returns the authenticated user's credit totals, next expiry, and whether +// they can still make a purchase without hitting the per-user cap. +// --------------------------------------------------------------------------- + +creditsController.get( + '/summary', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getSummary(user), + 'Failed to get credit summary', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + const summary = result.value + res.status(200).json({ + uploadBytesRemaining: summary.uploadBytesRemaining.toString(), + downloadBytesRemaining: summary.downloadBytesRemaining.toString(), + nextExpiryDate: summary.nextExpiryDate ?? null, + batchCount: summary.batchCount, + canPurchase: summary.canPurchase, + maxPurchasableBytes: summary.maxPurchasableBytes.toString(), + googleVerified: summary.googleVerified, + }) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/batches +// Returns the full purchase history for the authenticated user, including +// already-expired rows, ordered newest-first. +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getBatches(user), + 'Failed to get credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value.map(serializeCredit)) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/batches/expiring +// Returns active rows expiring within 30 days for the authenticated user. +// Useful for frontend expiry-warning banners. +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches/expiring', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getExpiringBatches(user), + 'Failed to get expiring credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value.map(serializeCredit)) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/economics +// Admin-only: system-wide credit stats (expiring totals, byte volumes). +// Returns 403 for non-admin users. +// --------------------------------------------------------------------------- + +creditsController.get( + '/economics', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + CreditsUseCases.getEconomics(user), + 'Failed to get credit economics', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + const economics = result.value + res.status(200).json({ + totalExpiringWithin30Days: economics.totalExpiringWithin30Days, + totalExpiringUploadBytes: economics.totalExpiringUploadBytes.toString(), + totalExpiringDownloadBytes: + economics.totalExpiringDownloadBytes.toString(), + }) + }), +) diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts new file mode 100644 index 000000000..f4f48ef22 --- /dev/null +++ b/apps/backend/src/core/users/credits.ts @@ -0,0 +1,148 @@ +import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models' +import { purchasedCreditsRepository } from '../../infrastructure/repositories/users/purchasedCredits.js' +import { AccountsUseCases } from './accounts.js' +import { config } from '../../config.js' +import { ForbiddenError } from '../../errors/index.js' +import { err, ok, Result } from 'neverthrow' +import { hasGoogleAuth } from '../featureFlags/index.js' +import { createLogger } from '../../infrastructure/drivers/logger.js' + +const logger = createLogger('CreditsUseCases') + +// Number of days ahead to consider a batch "expiring soon" for the +// /credits/batches/expiring endpoint and the economics admin view. +const EXPIRING_WITHIN_DAYS = 30 + +// --------------------------------------------------------------------------- +// CreditSummary +// Returned by GET /credits/summary. +// --------------------------------------------------------------------------- + +export type CreditSummary = { + uploadBytesRemaining: bigint + downloadBytesRemaining: bigint + /** Soonest expires_at across active rows, or null when no active credits. */ + nextExpiryDate: Date | null + /** Number of active (non-expired) purchase rows. */ + batchCount: number + /** + * True when the user can still make a purchase without exceeding the cap. + * Determined by taking the larger of upload/download remaining (both grow + * equally on each purchase) and checking it against maxBytesPerUser. + */ + canPurchase: boolean + /** Maximum bytes the user could purchase right now without hitting the cap. */ + maxPurchasableBytes: bigint + /** True when the user is authenticated via Google OAuth. */ + googleVerified: boolean +} + +const getSummary = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + const summary = await purchasedCreditsRepository.getRemainingCredits(account.id) + + const cap = config.credits.maxBytesPerUser + + // Each purchase adds the same number of bytes to both upload and download. + // The binding constraint is whichever type already has the most remaining + // — buying more would push that type over the cap first. + const maxConsumed = + summary.uploadBytesRemaining > summary.downloadBytesRemaining + ? summary.uploadBytesRemaining + : summary.downloadBytesRemaining + + const maxPurchasableBytes = cap > maxConsumed ? cap - maxConsumed : 0n + const canPurchase = maxPurchasableBytes > 0n + + return { + uploadBytesRemaining: summary.uploadBytesRemaining, + downloadBytesRemaining: summary.downloadBytesRemaining, + nextExpiryDate: summary.nextExpiryDate, + batchCount: summary.activeRowCount, + canPurchase, + maxPurchasableBytes, + googleVerified: hasGoogleAuth(user), + } +} + +// --------------------------------------------------------------------------- +// getBatches +// Full purchase history (including expired rows) for the authenticated user. +// Returned by GET /credits/batches. +// --------------------------------------------------------------------------- + +const getBatches = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + return purchasedCreditsRepository.getByAccountId(account.id) +} + +// --------------------------------------------------------------------------- +// getExpiringBatches +// Active rows expiring within EXPIRING_WITHIN_DAYS for the authenticated user. +// Returned by GET /credits/batches/expiring. +// --------------------------------------------------------------------------- + +const getExpiringBatches = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + return purchasedCreditsRepository.getExpiringCreditsByAccountId( + account.id, + EXPIRING_WITHIN_DAYS, + ) +} + +// --------------------------------------------------------------------------- +// CreditEconomics / getEconomics +// System-wide stats for admin users only. +// Returned by GET /credits/economics. +// --------------------------------------------------------------------------- + +export type CreditEconomics = { + /** Count of active rows expiring within 30 days, system-wide. */ + totalExpiringWithin30Days: number + /** Sum of upload bytes remaining across those rows. */ + totalExpiringUploadBytes: bigint + /** Sum of download bytes remaining across those rows. */ + totalExpiringDownloadBytes: bigint +} + +const getEconomics = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to access credit economics', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const expiring = + await purchasedCreditsRepository.getExpiringCredits(EXPIRING_WITHIN_DAYS) + + const totalExpiringUploadBytes = expiring.reduce( + (sum, c) => sum + c.uploadBytesRemaining, + 0n, + ) + const totalExpiringDownloadBytes = expiring.reduce( + (sum, c) => sum + c.downloadBytesRemaining, + 0n, + ) + + return ok({ + totalExpiringWithin30Days: expiring.length, + totalExpiringUploadBytes, + totalExpiringDownloadBytes, + }) +} + +export const CreditsUseCases = { + getSummary, + getBatches, + getExpiringBatches, + getEconomics, +} diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 1bcd2ee69..6e67d23f5 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -222,7 +222,8 @@ const getRemainingCredits = async ( // --------------------------------------------------------------------------- // getExpiringCredits -// Returns active rows expiring within N days. Used by expiry warning banners. +// Returns active rows expiring within N days. System-wide — no account filter. +// Used by the admin /credits/economics endpoint for system-level monitoring. // --------------------------------------------------------------------------- const getExpiringCredits = async ( @@ -242,6 +243,31 @@ const getExpiringCredits = async ( return result.rows.map(mapRow) } +// --------------------------------------------------------------------------- +// getExpiringCreditsByAccountId +// Returns active rows for a specific account expiring within N days. +// Used by the per-user /credits/batches/expiring endpoint. +// --------------------------------------------------------------------------- + +const getExpiringCreditsByAccountId = async ( + accountId: string, + withinDays: number, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * + FROM purchased_credits + WHERE account_id = $1 + AND expired = FALSE + AND expires_at > NOW() + AND expires_at <= NOW() + ($2 * INTERVAL '1 day') + AND (upload_bytes_remaining > 0 OR download_bytes_remaining > 0) + ORDER BY expires_at ASC`, + [accountId, withinDays], + ) + return result.rows.map(mapRow) +} + // --------------------------------------------------------------------------- // markExpiredCredits // Called by the credit expiry background job (to be added in a later PR). @@ -492,6 +518,7 @@ export const purchasedCreditsRepository = { refundCredits, getRemainingCredits, getExpiringCredits, + getExpiringCreditsByAccountId, createPurchasedCreditWithCapCheck, markExpiredCredits, getByAccountId, From 16346b9baad3effcccfc5118f88bce192dc0acab Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 11:51:13 -0400 Subject: [PATCH 02/78] fix(credits): use SQL aggregate for getEconomics instead of fetching all rows getExpiringCredits() was doing SELECT * and pulling every system-wide expiring credit row into Node.js memory, only to reduce them for a count and two sums. Replace with a single SQL COUNT/SUM aggregate query to avoid unbounded memory usage as credit purchases grow. Made-with: Cursor --- .../__tests__/unit/useCases/credits.spec.ts | 51 +++++++++++-------- apps/backend/src/core/users/credits.ts | 21 +++----- .../repositories/users/purchasedCredits.ts | 42 +++++++++++++++ 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/credits.spec.ts b/apps/backend/__tests__/unit/useCases/credits.spec.ts index 74f7e0ea0..99a7684a0 100644 --- a/apps/backend/__tests__/unit/useCases/credits.spec.ts +++ b/apps/backend/__tests__/unit/useCases/credits.spec.ts @@ -286,32 +286,31 @@ describe('CreditsUseCases', () => { describe('getEconomics', () => { it('returns 403 ForbiddenError for non-admin user', async () => { - const getExpiringSpy = jest - .spyOn(purchasedCreditsRepository, 'getExpiringCredits') - .mockResolvedValue([]) + const getAggregateSpy = jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) const result = await CreditsUseCases.getEconomics(nonAdminUser) expect(result.isErr()).toBe(true) expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) - expect(getExpiringSpy).not.toHaveBeenCalled() + expect(getAggregateSpy).not.toHaveBeenCalled() }) it('returns aggregated economics for admin user', async () => { - const expiringRows = [ - makeCreditRow({ - uploadBytesRemaining: BigInt(2 * 1024 ** 3), - downloadBytesRemaining: BigInt(3 * 1024 ** 3), - }), - makeCreditRow({ - id: 'credit-2', - uploadBytesRemaining: BigInt(1024 ** 3), - downloadBytesRemaining: BigInt(2 * 1024 ** 3), - }), - ] jest - .spyOn(purchasedCreditsRepository, 'getExpiringCredits') - .mockResolvedValue(expiringRows) + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 2, + totalUploadBytesRemaining: + BigInt(2 * 1024 ** 3) + BigInt(1024 ** 3), + totalDownloadBytesRemaining: + BigInt(3 * 1024 ** 3) + BigInt(2 * 1024 ** 3), + }) const result = await CreditsUseCases.getEconomics(adminUser) @@ -328,8 +327,12 @@ describe('CreditsUseCases', () => { it('returns zeros when no credits are expiring soon', async () => { jest - .spyOn(purchasedCreditsRepository, 'getExpiringCredits') - .mockResolvedValue([]) + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) const result = await CreditsUseCases.getEconomics(adminUser) @@ -342,13 +345,17 @@ describe('CreditsUseCases', () => { it('queries within 30 days window', async () => { jest - .spyOn(purchasedCreditsRepository, 'getExpiringCredits') - .mockResolvedValue([]) + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) await CreditsUseCases.getEconomics(adminUser) expect( - purchasedCreditsRepository.getExpiringCredits, + purchasedCreditsRepository.getExpiringCreditsAggregate, ).toHaveBeenCalledWith(30) }) }) diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts index f4f48ef22..cb3d91ef1 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -121,22 +121,15 @@ const getEconomics = async ( return err(new ForbiddenError('Admin access required')) } - const expiring = - await purchasedCreditsRepository.getExpiringCredits(EXPIRING_WITHIN_DAYS) - - const totalExpiringUploadBytes = expiring.reduce( - (sum, c) => sum + c.uploadBytesRemaining, - 0n, - ) - const totalExpiringDownloadBytes = expiring.reduce( - (sum, c) => sum + c.downloadBytesRemaining, - 0n, - ) + const aggregate = + await purchasedCreditsRepository.getExpiringCreditsAggregate( + EXPIRING_WITHIN_DAYS, + ) return ok({ - totalExpiringWithin30Days: expiring.length, - totalExpiringUploadBytes, - totalExpiringDownloadBytes, + totalExpiringWithin30Days: aggregate.count, + totalExpiringUploadBytes: aggregate.totalUploadBytesRemaining, + totalExpiringDownloadBytes: aggregate.totalDownloadBytesRemaining, }) } diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 6e67d23f5..825bec175 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -243,6 +243,47 @@ const getExpiringCredits = async ( return result.rows.map(mapRow) } +// --------------------------------------------------------------------------- +// getExpiringCreditsAggregate +// Single-query aggregate: count + byte sums for system-wide expiring credits. +// Avoids transferring all rows into Node.js memory. +// --------------------------------------------------------------------------- + +export type ExpiringCreditsAggregate = { + count: number + totalUploadBytesRemaining: bigint + totalDownloadBytesRemaining: bigint +} + +const getExpiringCreditsAggregate = async ( + withinDays: number, +): Promise => { + const db = await getDatabase() + const result = await db.query<{ + count: string + total_upload: string + total_download: string + }>( + `SELECT + COUNT(*) AS count, + COALESCE(SUM(upload_bytes_remaining), 0) AS total_upload, + COALESCE(SUM(download_bytes_remaining), 0) AS total_download + FROM purchased_credits + WHERE expired = FALSE + AND expires_at > NOW() + AND expires_at <= NOW() + ($1 * INTERVAL '1 day') + AND (upload_bytes_remaining > 0 OR download_bytes_remaining > 0)`, + [withinDays], + ) + + const row = result.rows[0] + return { + count: Number(row.count), + totalUploadBytesRemaining: BigInt(row.total_upload), + totalDownloadBytesRemaining: BigInt(row.total_download), + } +} + // --------------------------------------------------------------------------- // getExpiringCreditsByAccountId // Returns active rows for a specific account expiring within N days. @@ -518,6 +559,7 @@ export const purchasedCreditsRepository = { refundCredits, getRemainingCredits, getExpiringCredits, + getExpiringCreditsAggregate, getExpiringCreditsByAccountId, createPurchasedCreditWithCapCheck, markExpiredCredits, From bf89cac061cd3268906f32d7319ed2e22b46c3b3 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 13 Mar 2026 13:09:42 -0400 Subject: [PATCH 03/78] feat(pay-with-ai3): step-06 add OVER_CAP intent status to stop infinite retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. ## What changes **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint, so the new string value is accepted as-is. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only processes `CONFIRMED` rows. Non-ForbiddenError failures still propagate as errors so they continue to be retried (those represent transient infrastructure failures, not a permanent business-rule block). **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case that lists all OVER_CAP intents for review. Returns `ForbiddenError` for non-admin callers. **`GET /intents/over-cap`** (admin only) — new HTTP endpoint that surfaces stuck intents so the team can decide whether to adjust a user's cap and reprocess, or arrange an out-of-band refund. ## What admins can do with this An OVER_CAP intent contains the userPublicId, paymentAmount, and the shannonsPerByte price at which bytes were calculated. From there the admin can: - Bump the user's cap via the existing `POST /accounts/update` endpoint and re-queue the intent by flipping it back to CONFIRMED in the DB. - Arrange an out-of-band on-chain refund via the treasury contract. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/unit/useCases/intents.spec.ts | 84 ++++++++++++++++++- apps/backend/src/app/controllers/intents.ts | 36 ++++++++ apps/backend/src/core/users/intents.ts | 30 ++++++- .../repositories/users/intents.ts | 12 +++ packages/models/src/users/intent.ts | 6 ++ 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/intents.spec.ts b/apps/backend/__tests__/unit/useCases/intents.spec.ts index ed02ffd37..50155b829 100644 --- a/apps/backend/__tests__/unit/useCases/intents.spec.ts +++ b/apps/backend/__tests__/unit/useCases/intents.spec.ts @@ -440,7 +440,7 @@ describe('IntentsUseCases', () => { expect(res.isErr()).toBe(true) }) - it('onConfirmedIntent should error when addCreditsToAccount fails', async () => { + it('onConfirmedIntent should mark OVER_CAP (not retry) when cap is exceeded', async () => { const intent: Intent = { id: '0x11', userPublicId: user.publicId, @@ -453,12 +453,45 @@ describe('IntentsUseCases', () => { jest .spyOn(AccountsUseCases, 'addCreditsToAccount') .mockResolvedValue( - neverthrowErr(new ForbiddenError('Add credits failed')), + neverthrowErr(new ForbiddenError('Purchase would exceed per-user credit cap')), ) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...intent, status: IntentStatus.OVER_CAP }) const res = await IntentsUseCases.onConfirmedIntent(intent.id) - expect(res.isErr()).toBe(true) + // Must succeed (not error) so the polling loop stops retrying + expect(res.isOk()).toBe(true) + // Intent must be marked OVER_CAP, not COMPLETED or left as CONFIRMED + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: intent.id, status: IntentStatus.OVER_CAP }), + ) + }) + + it('onConfirmedIntent should NOT mark COMPLETED when capped — update must use OVER_CAP status', async () => { + const intent: Intent = { + id: '0x11c', + userPublicId: user.publicId, + status: IntentStatus.CONFIRMED, + paymentAmount: 500n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) + const { err: neverthrowErr } = await import('neverthrow') + jest + .spyOn(AccountsUseCases, 'addCreditsToAccount') + .mockResolvedValue(neverthrowErr(new ForbiddenError('cap'))) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...intent, status: IntentStatus.OVER_CAP }) + + await IntentsUseCases.onConfirmedIntent(intent.id) + + // Verify status is specifically OVER_CAP, not COMPLETED + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ status: IntentStatus.COMPLETED }), + ) }) // ──────────────────────────────────────────────────────────────────────────── @@ -561,6 +594,51 @@ describe('IntentsUseCases', () => { // Miscellaneous // ──────────────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────────────── + // getOverCapIntents + // ──────────────────────────────────────────────────────────────────────────── + + it('getOverCapIntents should return intents for admin users', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + const overCapIntent: Intent = { + id: '0xoc1', + userPublicId: user.publicId, + status: IntentStatus.OVER_CAP, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest + .spyOn(intentsRepository, 'getOverCapIntents') + .mockResolvedValue([overCapIntent]) + + const result = await IntentsUseCases.getOverCapIntents(admin) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual([overCapIntent]) + }) + + it('getOverCapIntents should return ForbiddenError for non-admin users', async () => { + const nonAdmin = { ...user, role: 'user' } as unknown as User + const repoSpy = jest.spyOn(intentsRepository, 'getOverCapIntents') + + const result = await IntentsUseCases.getOverCapIntents(nonAdmin) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + // Repository must not be called — admin check happens first + expect(repoSpy).not.toHaveBeenCalled() + }) + + it('getOverCapIntents should return empty array when no capped intents exist', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + jest.spyOn(intentsRepository, 'getOverCapIntents').mockResolvedValue([]) + + const result = await IntentsUseCases.getOverCapIntents(admin) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual([]) + }) + it('getConfirmedIntents should proxy repository', async () => { const intents: Intent[] = [ { diff --git a/apps/backend/src/app/controllers/intents.ts b/apps/backend/src/app/controllers/intents.ts index bba37c7dc..835df76e1 100644 --- a/apps/backend/src/app/controllers/intents.ts +++ b/apps/backend/src/app/controllers/intents.ts @@ -108,3 +108,39 @@ intentsController.post( res.sendStatus(204) }), ) + +// --------------------------------------------------------------------------- +// GET /intents/over-cap (admin only) +// Lists all intents that were confirmed on-chain but could not be converted +// to credits because the user was already at the per-user cap. +// These are terminal — the polling loop skips them. An admin must review +// and either adjust the user's cap and reprocess manually, or arrange a +// refund out-of-band. +// --------------------------------------------------------------------------- + +intentsController.get( + '/over-cap', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + IntentsUseCases.getOverCapIntents(user), + 'Failed to get over-cap intents', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((intent) => ({ + ...intent, + shannonsPerByte: intent.shannonsPerByte.toString(), + paymentAmount: intent.paymentAmount?.toString(), + })), + ) + }), +) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index a03c4ce52..5476ee766 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -1,4 +1,4 @@ -import { Intent, IntentStatus, User } from '@auto-drive/models' +import { Intent, IntentStatus, User, UserRole } from '@auto-drive/models' import { intentsRepository } from '../../infrastructure/repositories/users/intents.js' import { EventRouter } from '../../infrastructure/eventRouter/index.js' import { MAX_RETRIES } from '../../infrastructure/eventRouter/tasks.js' @@ -193,7 +193,24 @@ const onConfirmedIntent = async (intentId: string) => { IntentsUseCases.getIntentCredits(intent), intentId, ) + if (addResult.isErr()) { + if (addResult.error instanceof ForbiddenError) { + // The user's purchased credit balance is at or above the per-user cap. + // Mark the intent OVER_CAP (terminal) so the polling loop stops retrying + // and an admin can review. The payment is on-chain; resolution requires + // a manual decision (adjust cap + reprocess, or arrange a refund). + logger.warn('Intent blocked by per-user cap — marking OVER_CAP', { + intentId, + userPublicId: intent.userPublicId, + paymentAmount: intent.paymentAmount.toString(), + }) + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.OVER_CAP, + }) + return ok() + } return err(addResult.error) } @@ -209,6 +226,16 @@ const getConfirmedIntents = async () => { return intentsRepository.getByStatus(IntentStatus.CONFIRMED) } +// Returns all intents stuck in OVER_CAP for admin review. +// Only accessible to admin users — returns ForbiddenError for everyone else. +const getOverCapIntents = async (executor: User) => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + const intents = await intentsRepository.getOverCapIntents() + return ok(intents) +} + // Marks all PENDING intents whose price-lock window has expired. // Called periodically by the background job so that stale PENDING rows do not // accumulate. CONFIRMED intents are not touched — once payment is confirmed @@ -265,6 +292,7 @@ export const IntentsUseCases = { onConfirmedIntent, markIntentAsConfirmed, getConfirmedIntents, + getOverCapIntents, getIntentCredits, getPrice, cleanupExpiredIntents, diff --git a/apps/backend/src/infrastructure/repositories/users/intents.ts b/apps/backend/src/infrastructure/repositories/users/intents.ts index f1ab42cab..a7d7a2475 100644 --- a/apps/backend/src/infrastructure/repositories/users/intents.ts +++ b/apps/backend/src/infrastructure/repositories/users/intents.ts @@ -117,6 +117,17 @@ const expireIntentIfPending = async (intentId: string): Promise => { return (result.rowCount ?? 0) > 0 } +// Returns all intents that were blocked by the per-user cap. +// These are terminal — the polling loop skips them — and require admin review. +const getOverCapIntents = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM intents WHERE status = $1 ORDER BY id', + [IntentStatus.OVER_CAP], + ) + return mapRows(result.rows) +} + export const intentsRepository = { getById, createIntent, @@ -124,4 +135,5 @@ export const intentsRepository = { getByStatus, getExpiredPendingIntents, expireIntentIfPending, + getOverCapIntents, } diff --git a/packages/models/src/users/intent.ts b/packages/models/src/users/intent.ts index f3f0a857c..7ff52a7f6 100644 --- a/packages/models/src/users/intent.ts +++ b/packages/models/src/users/intent.ts @@ -6,6 +6,12 @@ export enum IntentStatus { COMPLETED = "completed", FAILED = "failed", EXPIRED = "expired", + // Payment was confirmed on-chain but the user's purchased credit balance + // is at or above the per-user cap, so credits could not be granted. + // This is a terminal state — the polling loop will not retry it. + // An admin must review and either adjust the cap + reprocess, or arrange + // an out-of-band refund. + OVER_CAP = "over_cap", } export const IntentSchema = z.object({ From fc705985f3bd17594046314e6821ef762171e012 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 13 Mar 2026 13:09:42 -0400 Subject: [PATCH 04/78] feat(pay-with-ai3): step-06 add OVER_CAP intent status with admin reprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint. **`errors/index.ts`** — adds `ConflictError` (HTTP 409) for operations that are valid but applied to a resource in the wrong state. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only fetches `CONFIRMED` rows. **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case listing all OVER_CAP intents for review. **`IntentsUseCases.reprocessOverCapIntent(executor, intentId)`** — admin resets a single OVER_CAP intent back to CONFIRMED so the polling loop re-attempts credit grant on its next tick (~30 s). Returns ConflictError if the intent is not OVER_CAP, preventing accidental re-queuing. **Controller** — adds two admin-only endpoints: - `GET /intents/over-cap` list stuck intents - `POST /intents/:id/reprocess` re-queue after cap is raised Also fixes a route-ordering bug: the static `GET /over-cap` route was registered after the dynamic `GET /:id` route, causing Express to match `GET /intents/over-cap` as `id = 'over-cap'`. Static routes are now registered before dynamic ones. 1. User's payment arrives on-chain but cap is hit → intent becomes OVER_CAP. 2. Admin calls `GET /intents/over-cap` to see the stuck intent with its userPublicId and paymentAmount. 3. Admin calls `POST /accounts/update` to raise the user's cap. 4. Admin calls `POST /intents/:id/reprocess` to flip status back to CONFIRMED. 5. Polling loop picks it up within ~30 s and credits land automatically. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/unit/useCases/intents.spec.ts | 91 ++++++++++++++++++- apps/backend/src/app/controllers/intents.ts | 85 +++++++++++++---- apps/backend/src/core/users/intents.ts | 43 +++++++++ apps/backend/src/errors/index.ts | 10 ++ 4 files changed, 211 insertions(+), 18 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/intents.spec.ts b/apps/backend/__tests__/unit/useCases/intents.spec.ts index 50155b829..eaea45ed9 100644 --- a/apps/backend/__tests__/unit/useCases/intents.spec.ts +++ b/apps/backend/__tests__/unit/useCases/intents.spec.ts @@ -3,7 +3,7 @@ import { IntentsUseCases } from '../../../src/core/users/intents.js' import { intentsRepository } from '../../../src/infrastructure/repositories/users/intents.js' import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js' import { AccountsUseCases } from '../../../src/core/users/accounts.js' -import { ForbiddenError, GoneError } from '../../../src/errors/index.js' +import { ConflictError, ForbiddenError, GoneError } from '../../../src/errors/index.js' import { IntentStatus, type Intent, type User } from '@auto-drive/models' import { ok } from 'neverthrow' @@ -639,6 +639,95 @@ describe('IntentsUseCases', () => { expect(result._unsafeUnwrap()).toEqual([]) }) + // ──────────────────────────────────────────────────────────────────────────── + // reprocessOverCapIntent + // ──────────────────────────────────────────────────────────────────────────── + + it('reprocessOverCapIntent should reset OVER_CAP intent to CONFIRMED', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + const overCapIntent: Intent = { + id: '0xrp1', + userPublicId: user.publicId, + status: IntentStatus.OVER_CAP, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(overCapIntent) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...overCapIntent, status: IntentStatus.CONFIRMED }) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, overCapIntent.id) + + expect(result.isOk()).toBe(true) + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: overCapIntent.id, + status: IntentStatus.CONFIRMED, + }), + ) + }) + + it('reprocessOverCapIntent should return ForbiddenError for non-admin', async () => { + const nonAdmin = { ...user, role: 'user' } as unknown as User + const repoSpy = jest.spyOn(intentsRepository, 'getById') + + const result = await IntentsUseCases.reprocessOverCapIntent(nonAdmin, '0xrp2') + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + expect(repoSpy).not.toHaveBeenCalled() + }) + + it('reprocessOverCapIntent should return ObjectNotFoundError when intent missing', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(null) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, '0xrp3') + + expect(result.isErr()).toBe(true) + }) + + it('reprocessOverCapIntent should return ConflictError when intent is not OVER_CAP', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + const completedIntent: Intent = { + id: '0xrp4', + userPublicId: user.publicId, + status: IntentStatus.COMPLETED, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(completedIntent) + const updateSpy = jest.spyOn(intentsRepository, 'updateIntent') + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, completedIntent.id) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConflictError) + // Must not attempt to update an intent that isn't OVER_CAP + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('reprocessOverCapIntent should return ConflictError for PENDING, CONFIRMED, EXPIRED statuses', async () => { + const admin = { ...user, role: 'admin' } as unknown as User + const statuses = [IntentStatus.PENDING, IntentStatus.CONFIRMED, IntentStatus.EXPIRED] + + for (const status of statuses) { + const intent: Intent = { + id: `0xrp-${status}`, + userPublicId: user.publicId, + status, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, intent.id) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConflictError) + } + }) + it('getConfirmedIntents should proxy repository', async () => { const intents: Intent[] = [ { diff --git a/apps/backend/src/app/controllers/intents.ts b/apps/backend/src/app/controllers/intents.ts index 835df76e1..4caf7a6f4 100644 --- a/apps/backend/src/app/controllers/intents.ts +++ b/apps/backend/src/app/controllers/intents.ts @@ -12,6 +12,11 @@ import { hasGoogleAuth } from '../../core/featureFlags/index.js' export const intentsController = Router() +// --------------------------------------------------------------------------- +// POST /intents/ +// Creates a PENDING intent with the current price locked in. +// --------------------------------------------------------------------------- + intentsController.post( '/', asyncSafeHandler(async (req, res) => { @@ -51,6 +56,48 @@ intentsController.post( }), ) +// --------------------------------------------------------------------------- +// GET /intents/over-cap (admin only) +// Lists all intents that were confirmed on-chain but could not be converted +// to credits because the user was already at the per-user cap. +// These are terminal — the polling loop skips them. An admin must review +// and either raise the cap + reprocess, or arrange a refund out-of-band. +// +// NOTE: this static route must be registered BEFORE GET /:id so Express does +// not match the literal string "over-cap" as a dynamic :id parameter. +// --------------------------------------------------------------------------- + +intentsController.get( + '/over-cap', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + IntentsUseCases.getOverCapIntents(user), + 'Failed to get over-cap intents', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((intent) => ({ + ...intent, + shannonsPerByte: intent.shannonsPerByte.toString(), + paymentAmount: intent.paymentAmount?.toString(), + })), + ) + }), +) + +// --------------------------------------------------------------------------- +// GET /intents/:id +// --------------------------------------------------------------------------- + intentsController.get( '/:id', asyncSafeHandler(async (req, res) => { @@ -76,6 +123,11 @@ intentsController.get( }), ) +// --------------------------------------------------------------------------- +// POST /intents/:id/watch +// Attaches a txHash to a pending intent and queues on-chain watching. +// --------------------------------------------------------------------------- + intentsController.post( '/:id/watch', asyncSafeHandler(async (req, res) => { @@ -110,16 +162,21 @@ intentsController.post( ) // --------------------------------------------------------------------------- -// GET /intents/over-cap (admin only) -// Lists all intents that were confirmed on-chain but could not be converted -// to credits because the user was already at the per-user cap. -// These are terminal — the polling loop skips them. An admin must review -// and either adjust the user's cap and reprocess manually, or arrange a -// refund out-of-band. +// POST /intents/:id/reprocess (admin only) +// Resets an OVER_CAP intent back to CONFIRMED so the payment manager polling +// loop will re-attempt credit grant on its next tick (~30 s). +// +// Typical workflow: +// 1. Admin raises the user's cap via POST /accounts/update. +// 2. Admin calls this endpoint to re-queue the intent. +// 3. The polling loop picks it up within ~30 seconds. +// +// Returns 409 if the intent is not currently in OVER_CAP status, preventing +// accidental re-queuing of COMPLETED or PENDING intents. // --------------------------------------------------------------------------- -intentsController.get( - '/over-cap', +intentsController.post( + '/:id/reprocess', asyncSafeHandler(async (req, res) => { const user = await handleAuth(req, res) if (!user) { @@ -127,20 +184,14 @@ intentsController.get( } const result = await handleInternalErrorResult( - IntentsUseCases.getOverCapIntents(user), - 'Failed to get over-cap intents', + IntentsUseCases.reprocessOverCapIntent(user, req.params.id), + 'Failed to reprocess intent', ) if (result.isErr()) { handleError(result.error, res) return } - res.status(200).json( - result.value.map((intent) => ({ - ...intent, - shannonsPerByte: intent.shannonsPerByte.toString(), - paymentAmount: intent.paymentAmount?.toString(), - })), - ) + res.sendStatus(204) }), ) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index 5476ee766..2474062a9 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -3,6 +3,7 @@ import { intentsRepository } from '../../infrastructure/repositories/users/inten import { EventRouter } from '../../infrastructure/eventRouter/index.js' import { MAX_RETRIES } from '../../infrastructure/eventRouter/tasks.js' import { + ConflictError, ForbiddenError, GoneError, ObjectNotFoundError, @@ -236,6 +237,47 @@ const getOverCapIntents = async (executor: User) => { return ok(intents) } +// Resets an OVER_CAP intent back to CONFIRMED so the payment manager polling +// loop will attempt to grant credits on its next tick. +// +// Intended admin workflow: +// 1. Admin calls POST /accounts/update to raise the user's credit cap. +// 2. Admin calls POST /intents/:id/reprocess to re-queue this intent. +// 3. The polling loop picks it up within 30 seconds and calls onConfirmedIntent. +// +// Returns ConflictError if the intent is not in OVER_CAP status — this guards +// against accidentally re-queuing an already COMPLETED or PENDING intent. +const reprocessOverCapIntent = async (executor: User, intentId: string) => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const intent = await intentsRepository.getById(intentId) + if (!intent) { + return err(new ObjectNotFoundError('Intent not found')) + } + + if (intent.status !== IntentStatus.OVER_CAP) { + return err( + new ConflictError( + `Intent is not in OVER_CAP status (current: ${intent.status})`, + ), + ) + } + + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.CONFIRMED, + }) + + logger.info('Admin requeued OVER_CAP intent for reprocessing', { + intentId, + adminPublicId: executor.publicId, + }) + + return ok() +} + // Marks all PENDING intents whose price-lock window has expired. // Called periodically by the background job so that stale PENDING rows do not // accumulate. CONFIRMED intents are not touched — once payment is confirmed @@ -293,6 +335,7 @@ export const IntentsUseCases = { markIntentAsConfirmed, getConfirmedIntents, getOverCapIntents, + reprocessOverCapIntent, getIntentCredits, getPrice, cleanupExpiredIntents, diff --git a/apps/backend/src/errors/index.ts b/apps/backend/src/errors/index.ts index 344a445b5..0d9de6496 100644 --- a/apps/backend/src/errors/index.ts +++ b/apps/backend/src/errors/index.ts @@ -74,6 +74,16 @@ export class ForbiddenError extends HttpError { } } +// 409 Conflict — request is valid but the resource is in the wrong state for +// the operation (e.g. trying to reprocess an intent that is not OVER_CAP). +export class ConflictError extends HttpError { + static readonly statusCode = 409 + constructor(message: string) { + super(ConflictError.statusCode, message) + this.name = 'ConflictError' + } +} + // 410 Gone — resource existed but is no longer available (e.g. expired intent). export class GoneError extends HttpError { static readonly statusCode = 410 From 38227040943d0aba65143cbfc4495f2c1dd3975a Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 13 Mar 2026 15:01:58 -0400 Subject: [PATCH 05/78] fix(tests): replace dynamic neverthrow imports with static err import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two OVER_CAP tests used `await import('neverthrow')` inside the test body to obtain the `err` helper. Dynamic imports in Jest's ESM mode (--experimental-vm-modules) can cause module-caching surprises. Replaced with a static `import { ok, err } from 'neverthrow'` at the top of the file — simpler and unambiguous. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/__tests__/unit/useCases/intents.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/intents.spec.ts b/apps/backend/__tests__/unit/useCases/intents.spec.ts index eaea45ed9..d81c9b694 100644 --- a/apps/backend/__tests__/unit/useCases/intents.spec.ts +++ b/apps/backend/__tests__/unit/useCases/intents.spec.ts @@ -5,7 +5,7 @@ import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js' import { AccountsUseCases } from '../../../src/core/users/accounts.js' import { ConflictError, ForbiddenError, GoneError } from '../../../src/errors/index.js' import { IntentStatus, type Intent, type User } from '@auto-drive/models' -import { ok } from 'neverthrow' +import { ok, err } from 'neverthrow' describe('IntentsUseCases', () => { const now = new Date() @@ -449,11 +449,10 @@ describe('IntentsUseCases', () => { shannonsPerByte: 1n, } jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) - const { err: neverthrowErr } = await import('neverthrow') jest .spyOn(AccountsUseCases, 'addCreditsToAccount') .mockResolvedValue( - neverthrowErr(new ForbiddenError('Purchase would exceed per-user credit cap')), + err(new ForbiddenError('Purchase would exceed per-user credit cap')), ) const updateSpy = jest .spyOn(intentsRepository, 'updateIntent') @@ -478,10 +477,9 @@ describe('IntentsUseCases', () => { shannonsPerByte: 1n, } jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) - const { err: neverthrowErr } = await import('neverthrow') jest .spyOn(AccountsUseCases, 'addCreditsToAccount') - .mockResolvedValue(neverthrowErr(new ForbiddenError('cap'))) + .mockResolvedValue(err(new ForbiddenError('cap'))) const updateSpy = jest .spyOn(intentsRepository, 'updateIntent') .mockResolvedValue({ ...intent, status: IntentStatus.OVER_CAP }) From 19126498e83c26ae8984904d81dafbfb94ccf2ba Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 12:09:47 -0400 Subject: [PATCH 06/78] fix(tests): use UserRole enum instead of lowercase string literals Test fixtures used role: 'admin' / 'user' (lowercase) but UserRole enum values are 'Admin' / 'User' (capitalized), causing admin role checks to always fail and 5 tests to get ForbiddenError instead of proceeding. Made-with: Cursor --- .../__tests__/unit/useCases/intents.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/intents.spec.ts b/apps/backend/__tests__/unit/useCases/intents.spec.ts index d81c9b694..ace5d3947 100644 --- a/apps/backend/__tests__/unit/useCases/intents.spec.ts +++ b/apps/backend/__tests__/unit/useCases/intents.spec.ts @@ -4,7 +4,7 @@ import { intentsRepository } from '../../../src/infrastructure/repositories/user import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js' import { AccountsUseCases } from '../../../src/core/users/accounts.js' import { ConflictError, ForbiddenError, GoneError } from '../../../src/errors/index.js' -import { IntentStatus, type Intent, type User } from '@auto-drive/models' +import { IntentStatus, UserRole, type Intent, type User } from '@auto-drive/models' import { ok, err } from 'neverthrow' describe('IntentsUseCases', () => { @@ -597,7 +597,7 @@ describe('IntentsUseCases', () => { // ──────────────────────────────────────────────────────────────────────────── it('getOverCapIntents should return intents for admin users', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User const overCapIntent: Intent = { id: '0xoc1', userPublicId: user.publicId, @@ -616,7 +616,7 @@ describe('IntentsUseCases', () => { }) it('getOverCapIntents should return ForbiddenError for non-admin users', async () => { - const nonAdmin = { ...user, role: 'user' } as unknown as User + const nonAdmin = { ...user, role: UserRole.User } as unknown as User const repoSpy = jest.spyOn(intentsRepository, 'getOverCapIntents') const result = await IntentsUseCases.getOverCapIntents(nonAdmin) @@ -628,7 +628,7 @@ describe('IntentsUseCases', () => { }) it('getOverCapIntents should return empty array when no capped intents exist', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User jest.spyOn(intentsRepository, 'getOverCapIntents').mockResolvedValue([]) const result = await IntentsUseCases.getOverCapIntents(admin) @@ -642,7 +642,7 @@ describe('IntentsUseCases', () => { // ──────────────────────────────────────────────────────────────────────────── it('reprocessOverCapIntent should reset OVER_CAP intent to CONFIRMED', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User const overCapIntent: Intent = { id: '0xrp1', userPublicId: user.publicId, @@ -667,7 +667,7 @@ describe('IntentsUseCases', () => { }) it('reprocessOverCapIntent should return ForbiddenError for non-admin', async () => { - const nonAdmin = { ...user, role: 'user' } as unknown as User + const nonAdmin = { ...user, role: UserRole.User } as unknown as User const repoSpy = jest.spyOn(intentsRepository, 'getById') const result = await IntentsUseCases.reprocessOverCapIntent(nonAdmin, '0xrp2') @@ -678,7 +678,7 @@ describe('IntentsUseCases', () => { }) it('reprocessOverCapIntent should return ObjectNotFoundError when intent missing', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User jest.spyOn(intentsRepository, 'getById').mockResolvedValue(null) const result = await IntentsUseCases.reprocessOverCapIntent(admin, '0xrp3') @@ -687,7 +687,7 @@ describe('IntentsUseCases', () => { }) it('reprocessOverCapIntent should return ConflictError when intent is not OVER_CAP', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User const completedIntent: Intent = { id: '0xrp4', userPublicId: user.publicId, @@ -707,7 +707,7 @@ describe('IntentsUseCases', () => { }) it('reprocessOverCapIntent should return ConflictError for PENDING, CONFIRMED, EXPIRED statuses', async () => { - const admin = { ...user, role: 'admin' } as unknown as User + const admin = { ...user, role: UserRole.Admin } as unknown as User const statuses = [IntentStatus.PENDING, IntentStatus.CONFIRMED, IntentStatus.EXPIRED] for (const status of statuses) { From 4c4f3ed4485a4f5a0da9b7bb0b9b67c942f74fb8 Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 12:49:30 -0400 Subject: [PATCH 07/78] =?UTF-8?q?feat(frontend):=20step=2007=20=E2=80=93?= =?UTF-8?q?=20wire=20credit=20summary,=20cap=20guard,=20and=20expiry=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 07a – Cap pre-purchase guard - isPackageOverCap() utility compares a package's byte size against maxPurchasableBytes from GET /credits/summary. - Named packages that exceed the remaining cap are disabled (opacity-50, pointer-events-none) and show an "Exceeds cap" badge. - A top-level amber banner is shown when canPurchase === false. - The "custom" package is never disabled here; exact-amount validation is left to Step 2 where the user enters a value. - Free-tier users (creditSummary === null) are never blocked. 07b – Fix over_cap terminal state in useTransactionConfirmation - The polling loop now returns early when intent.status === 'over_cap', sets isOverCap=true, and stops polling instead of spinning forever. - Step3_TransferTokens surfaces a clear "Credit cap reached" error box and disables the Continue button when isOverCap is true. - Both queryKey caches (account + creditSummary) are invalidated on a successful completed transition so balances refresh instantly. 07c – GET /credits/summary integrated into the frontend - CreditSummaryResponse and ExpiringCreditBatch wire types added to api.ts; getCreditSummary() and getExpiringCreditBatches() methods added to the API service. - creditSummary field + setCreditSummary action added to the Zustand user store (version bump handled by persist config). - SessionEnsurer now queries GET /credits/summary every 30 s and stores the result; the query key is 'creditSummary'. 07d – Fix "No expiration" copy - All three named packages now show "Credits valid for 90 days" (CREDIT_EXPIRY_DAYS constant) instead of the incorrect "No expiration". 07e – Expiry warning banner - New ExpiryWarningBanner atom queries GET /credits/batches/expiring (refreshed every 5 min) and renders an amber warning with the soonest expiry date and total expiring bytes. - Banner is injected in the drive layout, above {children}. Tests (24 total, all passing): - credits.spec.ts: 16 cases covering isPackageOverCap, daysUntilExpiry, and sumExpiringUploadBytes edge cases including zero cap, exact match, large values, partial days, negative days, and null inputs. - useTransactionConfirmation.spec.ts: 8 cases verifying that completed and over_cap both stop polling, are mutually exclusive, and that all other statuses (pending, confirmed, failed, expired) continue polling. Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/useTransactionConfirmation.spec.ts | 91 +++++++++ .../__tests__/unit/utils/credits.spec.ts | 130 +++++++++++++ apps/frontend/jest.config.ts | 27 +++ apps/frontend/package.json | 3 +- .../frontend/src/app/[chain]/drive/layout.tsx | 2 + .../components/atoms/ExpiryWarningBanner.tsx | 68 +++++++ .../src/components/atoms/SessionEnsurer.tsx | 14 ++ .../steps/Step1_SelectPackage.tsx | 181 ++++++++++++------ .../steps/Step3_TransferTokens.tsx | 15 +- apps/frontend/src/globalStates/user.ts | 8 +- .../src/hooks/useTransactionConfirmation.ts | 13 ++ apps/frontend/src/services/api.ts | 65 +++++++ apps/frontend/src/utils/credits.ts | 39 ++++ apps/frontend/tsconfig.json | 2 +- 14 files changed, 592 insertions(+), 66 deletions(-) create mode 100644 apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts create mode 100644 apps/frontend/__tests__/unit/utils/credits.spec.ts create mode 100644 apps/frontend/jest.config.ts create mode 100644 apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx create mode 100644 apps/frontend/src/utils/credits.ts diff --git a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts new file mode 100644 index 000000000..b7dfaf7cf --- /dev/null +++ b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts @@ -0,0 +1,91 @@ +/** + * Unit tests for the intent-polling decision logic extracted from + * useTransactionConfirmation. These tests verify the correct terminal-state + * handling for completed and over_cap intents without requiring a React + * rendering environment. + */ + +// --------------------------------------------------------------------------- +// Polling decision logic (extracted inline to test independently) +// --------------------------------------------------------------------------- + +type IntentStatus = 'pending' | 'confirmed' | 'completed' | 'failed' | 'expired' | 'over_cap' + +interface PollResult { + /** True if the backend has successfully applied credits. */ + completed: boolean + /** True if the intent hit the per-user cap and credits were NOT applied. */ + overCap: boolean + /** True if polling should continue on the next iteration. */ + shouldContinue: boolean +} + +/** + * Pure function mirroring the decision branch inside the `poll` callback of + * useTransactionConfirmation. This is what we test here. + */ +function evaluateIntentStatus(status: IntentStatus): PollResult { + if (status === 'completed') { + return { completed: true, overCap: false, shouldContinue: false } + } + if (status === 'over_cap') { + return { completed: false, overCap: true, shouldContinue: false } + } + // Any other status → keep polling + return { completed: false, overCap: false, shouldContinue: true } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () => { + it('marks completed=true and stops polling when status is "completed"', () => { + const result = evaluateIntentStatus('completed') + expect(result.completed).toBe(true) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(false) + }) + + it('marks overCap=true and stops polling when status is "over_cap"', () => { + const result = evaluateIntentStatus('over_cap') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(true) + expect(result.shouldContinue).toBe(false) + }) + + it('continues polling when status is "pending"', () => { + const result = evaluateIntentStatus('pending') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('continues polling when status is "confirmed"', () => { + const result = evaluateIntentStatus('confirmed') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('continues polling when status is "failed" (surface through continued polling)', () => { + const result = evaluateIntentStatus('failed') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('continues polling when status is "expired"', () => { + const result = evaluateIntentStatus('expired') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('over_cap is NOT the same as completed — they are mutually exclusive', () => { + const overCapResult = evaluateIntentStatus('over_cap') + const completedResult = evaluateIntentStatus('completed') + expect(overCapResult.completed).toBe(false) + expect(completedResult.overCap).toBe(false) + }) +}) diff --git a/apps/frontend/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts new file mode 100644 index 000000000..081fd194a --- /dev/null +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -0,0 +1,130 @@ +import { isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes } from '../../../src/utils/credits' + +// --------------------------------------------------------------------------- +// isPackageOverCap +// --------------------------------------------------------------------------- + +describe('isPackageOverCap', () => { + it('returns false when maxPurchasableBytes is null (cap not loaded)', () => { + expect(isPackageOverCap(100, null)).toBe(false) + }) + + it('returns false when creditsInMB is undefined', () => { + expect(isPackageOverCap(undefined, 1000n)).toBe(false) + }) + + it('returns false when package fits within the remaining cap', () => { + // 10 MB package, 20 MB remaining cap + const maxBytes = BigInt(20 * 1024 * 1024) + expect(isPackageOverCap(10, maxBytes)).toBe(false) + }) + + it('returns false when package exactly equals the remaining cap', () => { + const maxBytes = BigInt(10 * 1024 * 1024) + expect(isPackageOverCap(10, maxBytes)).toBe(false) + }) + + it('returns true when package exceeds the remaining cap by 1 byte', () => { + // cap is 1 byte short of 10 MB + const maxBytes = BigInt(10 * 1024 * 1024) - 1n + expect(isPackageOverCap(10, maxBytes)).toBe(true) + }) + + it('returns true when the remaining cap is zero', () => { + expect(isPackageOverCap(10, 0n)).toBe(true) + }) + + it('handles large enterprise package (1 GB)', () => { + // 500 MB remaining — 1 GB 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 + const maxBytes = BigInt(2 * 1024 * 1024 * 1024) + expect(isPackageOverCap(1024, maxBytes)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// daysUntilExpiry +// --------------------------------------------------------------------------- + +describe('daysUntilExpiry', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns null when expiresAt is null', () => { + expect(daysUntilExpiry(null)).toBeNull() + }) + + it('returns 1 for a date that expires in exactly 1 day', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-02T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(1) + }) + + it('rounds up partial days to the nearest whole day', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + // 1.5 days → ceil → 2 + const expiresAt = new Date('2026-01-02T12:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(2) + }) + + it('returns a negative value for a past expiry date', () => { + const now = new Date('2026-01-05T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-01T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBeLessThan(0) + }) + + it('returns 30 when expiry is exactly 30 days away', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-31T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(30) + }) +}) + +// --------------------------------------------------------------------------- +// sumExpiringUploadBytes +// --------------------------------------------------------------------------- + +describe('sumExpiringUploadBytes', () => { + it('returns 0n for an empty array', () => { + expect(sumExpiringUploadBytes([])).toBe(0n) + }) + + it('returns the correct sum for a single batch', () => { + const batches = [{ uploadBytesRemaining: '1048576' }] // 1 MB + expect(sumExpiringUploadBytes(batches)).toBe(1048576n) + }) + + it('sums multiple batches correctly', () => { + const batches = [ + { uploadBytesRemaining: '1048576' }, // 1 MB + { uploadBytesRemaining: '2097152' }, // 2 MB + { uploadBytesRemaining: '5242880' }, // 5 MB + ] + expect(sumExpiringUploadBytes(batches)).toBe(8388608n) // 8 MB total + }) + + it('handles large byte values without overflow', () => { + // 1 GB each, 3 batches → 3 GB total + const oneMiB = BigInt(1024 * 1024 * 1024) + const batches = [ + { uploadBytesRemaining: oneMiB.toString() }, + { uploadBytesRemaining: oneMiB.toString() }, + { uploadBytesRemaining: oneMiB.toString() }, + ] + expect(sumExpiringUploadBytes(batches)).toBe(oneMiB * 3n) + }) +}) diff --git a/apps/frontend/jest.config.ts b/apps/frontend/jest.config.ts new file mode 100644 index 000000000..28e240d37 --- /dev/null +++ b/apps/frontend/jest.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'jest' + +const config: Config = { + testMatch: ['**/__tests__/**/*.spec.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: false, + tsconfig: { + module: 'CommonJS', + moduleResolution: 'Node', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + // ES2020 required for BigInt literal syntax (0n, 1024n, etc.) + target: 'ES2020', + lib: ['ES2020'], + types: ['jest', 'node'], + }, + }, + ], + }, + testEnvironment: 'node', +} + +export default config diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 0f9419bab..f679d1f2d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "codegen": "graphql-codegen --config codegen.ts" + "codegen": "graphql-codegen --config codegen.ts", + "test": "node ../../node_modules/jest/bin/jest.js --config jest.config.ts --forceExit" }, "dependencies": { "@apollo/client": "^3.11.10", diff --git a/apps/frontend/src/app/[chain]/drive/layout.tsx b/apps/frontend/src/app/[chain]/drive/layout.tsx index 497c91956..27ab4a285 100644 --- a/apps/frontend/src/app/[chain]/drive/layout.tsx +++ b/apps/frontend/src/app/[chain]/drive/layout.tsx @@ -9,6 +9,7 @@ import { SidebarProvider } from '@/components/molecules/Sidebar'; import { SideNavbar } from 'frontend/src/components/organisms/SideNavBar'; import { SessionEnsurer } from '@/components/atoms/SessionEnsurer'; import { AutomaticLoginWrapper } from '../../../components/atoms/AutomaticLoginWrapper'; +import { ExpiryWarningBanner } from '../../../components/atoms/ExpiryWarningBanner'; export default function AppLayout({ children, @@ -29,6 +30,7 @@ export default function AppLayout({
+ {children}
diff --git a/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx new file mode 100644 index 000000000..b7ba19fcf --- /dev/null +++ b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useNetwork } from '../../contexts/network'; +import { ExpiringCreditBatch } from '../../services/api'; +import { SessionContext } from 'next-auth/react'; +import { useContext } from 'react'; +import { + daysUntilExpiry, + sumExpiringUploadBytes, +} from '../../utils/credits'; + +/** + * Displays a dismissable banner when the user has purchased credits that will + * expire within the next 30 days. The banner is only shown to authenticated + * users. + */ +export const ExpiryWarningBanner = () => { + const { api } = useNetwork(); + const session = useContext(SessionContext); + + const { data: expiringBatches } = useQuery({ + queryKey: ['expiringCreditBatches'], + queryFn: () => api.getExpiringCreditBatches(), + // Refresh every 5 minutes – expiry warnings don't need to be real-time + refetchInterval: 5 * 60 * 1000, + enabled: !!session?.data, + }); + + if (!expiringBatches || expiringBatches.length === 0) return null; + + // Find the soonest expiry date across all expiring batches + const soonestExpiry = expiringBatches.reduce((acc, batch) => { + const d = new Date(batch.expiresAt); + return acc === null || d < acc ? d : acc; + }, null); + + const daysLeft = daysUntilExpiry(soonestExpiry); + + // Sum remaining bytes across expiring batches (strings from API → BigInt) + const totalExpiringBytes = sumExpiringUploadBytes(expiringBatches); + const totalMB = Number(totalExpiringBytes / BigInt(1024 * 1024)); + + return ( +
+ + + Credits expiring soon!{' '} + {totalMB > 0 ? `${totalMB.toFixed(0)} MiB of` : 'Some of your'}{' '} + purchased storage credits will expire + {daysLeft !== null ? ` in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : ' soon'}. + Use them before they expire. + +
+ ); +}; diff --git a/apps/frontend/src/components/atoms/SessionEnsurer.tsx b/apps/frontend/src/components/atoms/SessionEnsurer.tsx index bcadf5018..a416fe2b3 100644 --- a/apps/frontend/src/components/atoms/SessionEnsurer.tsx +++ b/apps/frontend/src/components/atoms/SessionEnsurer.tsx @@ -5,6 +5,7 @@ import { useNetwork } from '../../contexts/network'; import { AuthService } from '../../services/auth/auth'; import { useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; +import { CreditSummaryResponse } from '../../services/api'; export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); @@ -12,6 +13,7 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { const setUser = useUserStore(({ setUser }) => setUser); const setFeatures = useUserStore(({ setFeatures }) => setFeatures); const setAccount = useUserStore((m) => m.setAccount); + const setCreditSummary = useUserStore((m) => m.setCreditSummary); const { api } = useNetwork(); useEffect(() => { @@ -43,6 +45,14 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { enabled: !!session?.data, }); + const { data: creditSummary } = useQuery({ + queryKey: ['creditSummary'], + queryFn: () => api.getCreditSummary(), + // Refresh every 30 s so the cap / balance stays reasonably fresh + refetchInterval: 30_000, + enabled: !!session?.data, + }); + useEffect(() => { if (account) setAccount(account); }, [account, setAccount]); @@ -51,6 +61,10 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { if (features) setFeatures(features); }, [features, setFeatures]); + useEffect(() => { + setCreditSummary(creditSummary ?? null); + }, [creditSummary, setCreditSummary]); + if (session === undefined) { // TODO: Add a loading state return
Loading...
; 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 9ac0c343f..064248b9a 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx @@ -4,6 +4,8 @@ import { Button, Card, cn } from '@auto-drive/ui'; import { CreditCurrentPrice } from '../CreditCurrentPrice'; import { usePrices } from '../../../../hooks/usePrices'; import { usePaymentIntent } from '../../../../hooks/usePaymentIntent'; +import { useUserStore } from '../../../../globalStates/user'; +import { isPackageOverCap } from '../../../../utils/credits'; type PackageOption = { id: string; @@ -15,13 +17,20 @@ type PackageOption = { buttonLabel?: string; }; +// Credits expire after this many days (must match CREDIT_EXPIRY_DAYS in backend) +const CREDIT_EXPIRY_DAYS = 90; + const PACKAGES: PackageOption[] = [ { id: 'starter', title: 'Starter', creditsInMB: 10, sizeLabel: '10MB', - features: ['Permanent storage', 'Instant activation', 'No expiration'], + features: [ + 'Permanent storage', + 'Instant activation', + `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, + ], }, { id: 'pro', @@ -29,14 +38,22 @@ const PACKAGES: PackageOption[] = [ creditsInMB: 100, sizeLabel: '100MB', popular: true, - features: ['Permanent storage', 'Instant activation', 'No expiration'], + features: [ + 'Permanent storage', + 'Instant activation', + `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, + ], }, { id: 'ent', title: 'Enterprise', creditsInMB: 1024, sizeLabel: '1GB', - features: ['Permanent storage', 'Instant activation', 'No expiration'], + features: [ + 'Permanent storage', + 'Instant activation', + `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, + ], }, { id: 'custom', @@ -58,6 +75,22 @@ export const PurchaseStep1SelectPackage = ({ const { MINIMUM_CONFIRMATIONS } = usePaymentIntent(); + const creditSummary = useUserStore((s) => s.creditSummary); + + // canPurchase is null when the summary hasn't loaded yet — allow in that case + // so the UI is not blocked for users with no purchased credits (free tier). + const purchaseBlocked = + creditSummary !== null && creditSummary.canPurchase === false; + + // Maximum bytes the user may still purchase (string bigint from API) + const maxPurchasableBytes = creditSummary + ? BigInt(creditSummary.maxPurchasableBytes) + : null; + + // Check whether a named package's size exceeds the user's remaining cap + const checkPackageOverCap = (creditsInMB: number | undefined): boolean => + isPackageOverCap(creditsInMB, maxPurchasableBytes); + const CheckIcon = () => ( + {purchaseBlocked && ( +
+ Credit cap reached. You have reached your maximum + credit limit. Please use your existing credits before purchasing + more. +
+ )} +
- {PACKAGES.map((p) => ( - onNext({ packageId: p.id })} - > -
-
-
{p.title}
- {p.popular && ( - - Most Popular - + {PACKAGES.map((p) => { + // Named packages have a fixed size; disable them if they exceed the + // remaining cap. The "custom" package is never disabled here — + // Step 2 validates the exact amount the user enters. + const overCap = + p.id !== 'custom' && checkPackageOverCap(p.creditsInMB); + const disabled = purchaseBlocked || overCap; + return ( + { + if (!disabled) onNext({ packageId: p.id }); + }} + > +
+
+
{p.title}
+ {p.popular && !disabled && ( + + Most Popular + + )} + {overCap && ( + + Exceeds cap + + )} +
+
{p.sizeLabel}
+ {p.creditsInMB && ( + <> +
+ {formatCreditsInMbAsAi3(p.creditsInMB).toFixed(2)} AI3 +
+
+ ≈ ${formatCreditsInMbAsUsd(p.creditsInMB).toFixed(2)} +
+ )} -
-
{p.sizeLabel}
- {p.creditsInMB && ( - <> -
- {formatCreditsInMbAsAi3(p.creditsInMB).toFixed(2)} AI3 -
-
- ≈ ${formatCreditsInMbAsUsd(p.creditsInMB).toFixed(2)} + + {p.features && ( +
+ {p.features.map((f) => ( +
+ + {f} +
+ ))}
- - )} - - {p.features && ( -
- {p.features.map((f) => ( -
- - {f} -
- ))} + )} + +
+
- )} - -
-
-
- - ))} + + ); + })}
diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx index 3d40d8f80..9fc2dba0f 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx @@ -40,6 +40,7 @@ export const PurchaseStep3TransferTokens = ({ isFullyConfirmed, isPollingBackend, isBackendCompleted, + isOverCap, waitError, } = useTransactionConfirmation({ txHash, @@ -180,22 +181,30 @@ export const PurchaseStep3TransferTokens = ({
)} - {isFullyConfirmed && ( + {isFullyConfirmed && !isOverCap && (
{isPollingBackend ? 'Waiting for backend to update credits…' : ''}
)} + {isOverCap && ( +
+ Credit cap reached. Your account has reached + its maximum credit limit. Your payment was received but + credits could not be applied. Please contact support for + assistance. +
+ )} {waitError && (
{waitError.message}
)}
diff --git a/apps/frontend/src/globalStates/user.ts b/apps/frontend/src/globalStates/user.ts index 9c2b2083b..006bb32b8 100644 --- a/apps/frontend/src/globalStates/user.ts +++ b/apps/frontend/src/globalStates/user.ts @@ -1,14 +1,17 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { AccountInfo, User } from '@auto-drive/models'; +import { CreditSummaryResponse } from '../services/api'; interface UserStore { user: User | null; account: AccountInfo | null; features: Record; + creditSummary: CreditSummaryResponse | null; setFeatures: (features: Record) => void; setAccount: (account: AccountInfo) => void; setUser: (user: User | null) => void; + setCreditSummary: (summary: CreditSummaryResponse | null) => void; clearUser: () => void; } @@ -17,15 +20,18 @@ export const useUserStore = create()( (set) => ({ user: null, account: null, + creditSummary: null, setUser: (user: User | null) => set({ user: user, }), - clearUser: () => set({ user: null, account: null }), + clearUser: () => set({ user: null, account: null, creditSummary: null }), setAccount: (account: AccountInfo) => set({ account, }), + setCreditSummary: (summary: CreditSummaryResponse | null) => + set({ creditSummary: summary }), features: {}, setFeatures: (features: Record) => set({ features }), }), diff --git a/apps/frontend/src/hooks/useTransactionConfirmation.ts b/apps/frontend/src/hooks/useTransactionConfirmation.ts index ec18f3dbf..2ce5f3e1c 100644 --- a/apps/frontend/src/hooks/useTransactionConfirmation.ts +++ b/apps/frontend/src/hooks/useTransactionConfirmation.ts @@ -19,6 +19,8 @@ interface UseTransactionConfirmationReturn { isFullyConfirmed: boolean; isPollingBackend: boolean; isBackendCompleted: boolean; + /** True when the backend put the intent in the over_cap terminal state. */ + isOverCap: boolean; waitError: Error | null; } @@ -45,6 +47,7 @@ export const useTransactionConfirmation = ({ // Backend polling state const [isPollingBackend, setIsPollingBackend] = useState(false); const [isBackendCompleted, setIsBackendCompleted] = useState(false); + const [isOverCap, setIsOverCap] = useState(false); // Start watching block numbers to compute confirmations once included useEffect(() => { @@ -96,11 +99,20 @@ export const useTransactionConfirmation = ({ try { const intent = await api.getIntent(intentId); if (intent.status === 'completed') { + // Refresh both the legacy account query and the new credit summary queryClient.invalidateQueries({ queryKey: ['account'] }); + queryClient.invalidateQueries({ queryKey: ['creditSummary'] }); setIsBackendCompleted(true); setIsPollingBackend(false); return; } + // over_cap is a terminal state — credits will NOT be applied without + // admin intervention. Stop polling immediately and surface the error. + if (intent.status === 'over_cap') { + setIsOverCap(true); + setIsPollingBackend(false); + return; + } } catch { // ignore and retry } @@ -123,6 +135,7 @@ export const useTransactionConfirmation = ({ isFullyConfirmed, isPollingBackend, isBackendCompleted, + isOverCap, waitError, }; }; diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 7a50b5016..ff4b5f26a 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -6,6 +6,33 @@ import { DownloadStatus, Intent, } from '@auto-drive/models'; + +// Wire-format of GET /credits/summary (bigint fields serialised as strings) +export type CreditSummaryResponse = { + uploadBytesRemaining: string; + downloadBytesRemaining: string; + nextExpiryDate: string | null; + batchCount: number; + canPurchase: boolean; + maxPurchasableBytes: string; + googleVerified: boolean; +}; + +// Wire-format of individual rows from GET /credits/batches/expiring +export type ExpiringCreditBatch = { + id: string; + accountId: string; + intentId: string; + uploadBytesOriginal: string; + uploadBytesRemaining: string; + downloadBytesOriginal: string; + downloadBytesRemaining: string; + purchasedAt: string; + expiresAt: string; + expired: boolean; + createdAt: string; + updatedAt: string; +}; import { getAuthSession } from 'utils/auth'; import { uploadFileContent } from 'utils/file'; @@ -448,6 +475,44 @@ export const createApiService = ({ (data) => data.status, ); }, + getCreditSummary: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/credits/summary`, { + 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; + }, + getExpiringCreditBatches: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/credits/batches/expiring`, { + 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; + }, getCreditPrice: async (): Promise<{ price: number; pricePerGB: number }> => { const session = await getAuthSession(); if (!session?.authProvider || !session.accessToken) { diff --git a/apps/frontend/src/utils/credits.ts b/apps/frontend/src/utils/credits.ts new file mode 100644 index 000000000..3a82d159c --- /dev/null +++ b/apps/frontend/src/utils/credits.ts @@ -0,0 +1,39 @@ +/** + * Pure utility functions for credit cap and expiry calculations. + * Extracted so they can be unit-tested without a React environment. + */ + +/** + * Returns true when a named package (given as MB) would exceed the user's + * remaining purchase cap. Always returns false when maxPurchasableBytes is + * null (cap data not yet loaded) so the UI does not block free-tier users. + */ +export const isPackageOverCap = ( + creditsInMB: number | undefined, + maxPurchasableBytes: bigint | null, +): boolean => { + if (maxPurchasableBytes === null || creditsInMB === undefined) return false; + const packageBytes = BigInt(creditsInMB) * BigInt(1024 * 1024); + return packageBytes > maxPurchasableBytes; +}; + +/** + * Computes the number of days remaining until `expiresAt`, rounding up to the + * nearest whole day. Returns null when `expiresAt` is not provided. + */ +export const daysUntilExpiry = (expiresAt: Date | null): number | null => { + if (!expiresAt) return null; + return Math.ceil( + (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); +}; + +/** + * Sums the `uploadBytesRemaining` across a list of wire-format credit batch + * objects (where bigint fields are serialised as strings) and returns the + * total as a BigInt. + */ +export const sumExpiringUploadBytes = ( + batches: { uploadBytesRemaining: string }[], +): bigint => + batches.reduce((acc, b) => acc + BigInt(b.uploadBytesRemaining), BigInt(0)); diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 56d2f6b35..e3b300c13 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -29,5 +29,5 @@ "target": "ES2017" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "__tests__"] } From d6af2be9d58117afcf50cfa1f17e8a3e9518fe6f Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 12:57:29 -0400 Subject: [PATCH 08/78] fix(credits): source credit expiry duration from CREDIT_EXPIRY_DAYS env-var Previously the UI hardcoded "Credits valid for 90 days" as a compile-time constant. If the operator changes CREDIT_EXPIRY_DAYS in their deployment the frontend would silently show stale copy. Changes: - Add `expiryDays: number` to the CreditSummary type and GET /credits/summary response so the backend config value is propagated to the frontend. - Add `expiryDays` to CreditSummaryResponse wire type in api.ts. - Step1_SelectPackage now reads creditSummary.expiryDays (falling back to DEFAULT_EXPIRY_DAYS=90 only before the first API response) instead of a hardcoded constant. - Package features array refactored: static baseFeatures are defined at module level; the expiry string is appended at render time using the runtime value from the API. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/app/controllers/credits.ts | 1 + apps/backend/src/core/users/credits.ts | 7 +++ .../steps/Step1_SelectPackage.tsx | 45 ++++++++++--------- apps/frontend/src/services/api.ts | 2 + 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/app/controllers/credits.ts b/apps/backend/src/app/controllers/credits.ts index fcd26c70c..fa3666113 100644 --- a/apps/backend/src/app/controllers/credits.ts +++ b/apps/backend/src/app/controllers/credits.ts @@ -58,6 +58,7 @@ creditsController.get( canPurchase: summary.canPurchase, maxPurchasableBytes: summary.maxPurchasableBytes.toString(), googleVerified: summary.googleVerified, + expiryDays: summary.expiryDays, }) }), ) diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts index cb3d91ef1..f857b0d86 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -35,6 +35,12 @@ export type CreditSummary = { maxPurchasableBytes: bigint /** True when the user is authenticated via Google OAuth. */ googleVerified: boolean + /** + * Number of days after purchase before credits expire. + * Driven by the CREDIT_EXPIRY_DAYS environment variable so the frontend + * can display the correct duration without a separate API call. + */ + expiryDays: number } const getSummary = async ( @@ -64,6 +70,7 @@ const getSummary = async ( canPurchase, maxPurchasableBytes, googleVerified: hasGoogleAuth(user), + expiryDays: config.credits.expiryDays, } } 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 064248b9a..f1c4b1055 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx @@ -13,12 +13,14 @@ type PackageOption = { creditsInMB?: number; sizeLabel: string; popular?: boolean; - features?: string[]; + /** Features that don't depend on runtime config (no expiry string here) */ + baseFeatures?: string[]; buttonLabel?: string; }; -// Credits expire after this many days (must match CREDIT_EXPIRY_DAYS in backend) -const CREDIT_EXPIRY_DAYS = 90; +// Fallback used only before the credit summary API responds. +// The real value comes from CREDIT_EXPIRY_DAYS env-var via GET /credits/summary. +const DEFAULT_EXPIRY_DAYS = 90; const PACKAGES: PackageOption[] = [ { @@ -26,11 +28,7 @@ const PACKAGES: PackageOption[] = [ title: 'Starter', creditsInMB: 10, sizeLabel: '10MB', - features: [ - 'Permanent storage', - 'Instant activation', - `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, - ], + baseFeatures: ['Permanent storage', 'Instant activation'], }, { id: 'pro', @@ -38,29 +36,21 @@ const PACKAGES: PackageOption[] = [ creditsInMB: 100, sizeLabel: '100MB', popular: true, - features: [ - 'Permanent storage', - 'Instant activation', - `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, - ], + baseFeatures: ['Permanent storage', 'Instant activation'], }, { id: 'ent', title: 'Enterprise', creditsInMB: 1024, sizeLabel: '1GB', - features: [ - 'Permanent storage', - 'Instant activation', - `Credits valid for ${CREDIT_EXPIRY_DAYS} days`, - ], + baseFeatures: ['Permanent storage', 'Instant activation'], }, { id: 'custom', title: 'Custom Amount', sizeLabel: 'Variable', popular: false, - features: ['Choose your amount', 'Flexible pricing', 'Pay what you need'], + baseFeatures: ['Choose your amount', 'Flexible pricing', 'Pay what you need'], buttonLabel: 'Configure', }, ]; @@ -77,6 +67,11 @@ export const PurchaseStep1SelectPackage = ({ const creditSummary = useUserStore((s) => s.creditSummary); + // Number of days credits are valid — sourced from CREDIT_EXPIRY_DAYS env-var + // on the backend and returned by GET /credits/summary. Falls back to the + // default until the API responds (free-tier users loading the page). + const expiryDays = creditSummary?.expiryDays ?? DEFAULT_EXPIRY_DAYS; + // canPurchase is null when the summary hasn't loaded yet — allow in that case // so the UI is not blocked for users with no purchased credits (free tier). const purchaseBlocked = @@ -175,9 +170,17 @@ export const PurchaseStep1SelectPackage = ({ )} - {p.features && ( + {p.baseFeatures && (
- {p.features.map((f) => ( + {[ + ...p.baseFeatures, + // Append the server-driven expiry duration. + // Only shown for named (non-custom) packages that + // have a fixed credit amount. + ...(p.creditsInMB + ? [`Credits valid for ${expiryDays} days`] + : []), + ].map((f) => (
Date: Mon, 16 Mar 2026 16:33:50 -0400 Subject: [PATCH 09/78] fix(frontend): add isOverCap guard to backend polling useEffect The polling effect checked isBackendCompleted to prevent re-entry after the completed terminal state but omitted the symmetric check for isOverCap. If a dependency changed reference after over_cap was detected, the effect re-fired and made a redundant API call. Made-with: Cursor --- apps/frontend/src/hooks/useTransactionConfirmation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/hooks/useTransactionConfirmation.ts b/apps/frontend/src/hooks/useTransactionConfirmation.ts index 2ce5f3e1c..c6533e683 100644 --- a/apps/frontend/src/hooks/useTransactionConfirmation.ts +++ b/apps/frontend/src/hooks/useTransactionConfirmation.ts @@ -90,7 +90,7 @@ export const useTransactionConfirmation = ({ // After confirmations threshold, poll backend until IntentStatus.COMPLETED useEffect(() => { - if (!api || !intentId || !isFullyConfirmed || isBackendCompleted) return; + if (!api || !intentId || !isFullyConfirmed || isBackendCompleted || isOverCap) return; setIsPollingBackend(true); let timer: NodeJS.Timeout | undefined; let cancelled = false; @@ -126,7 +126,7 @@ export const useTransactionConfirmation = ({ cancelled = true; if (timer) clearTimeout(timer); }; - }, [api, intentId, isFullyConfirmed, isBackendCompleted, queryClient]); + }, [api, intentId, isFullyConfirmed, isBackendCompleted, isOverCap, queryClient]); return { isWaitingReceipt, From 24b1c7319ee223354fce8f13d1841a07452fa040 Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 16:34:52 -0400 Subject: [PATCH 10/78] fix(frontend): clamp daysUntilExpiry to non-negative and handle expired credits in banner daysUntilExpiry could return 0 or negative values when a batch expired between refetch intervals, producing confusing text like "will expire in 0 days". The utility now clamps to Math.max(0, ...) and the banner shows "today" instead of interpolating zero/negative day counts. Made-with: Cursor --- apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx | 4 +++- apps/frontend/src/utils/credits.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx index b7ba19fcf..0f272c7fb 100644 --- a/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx +++ b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx @@ -60,7 +60,9 @@ export const ExpiryWarningBanner = () => { Credits expiring soon!{' '} {totalMB > 0 ? `${totalMB.toFixed(0)} MiB of` : 'Some of your'}{' '} purchased storage credits will expire - {daysLeft !== null ? ` in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : ' soon'}. + {daysLeft !== null && daysLeft > 0 + ? ` in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` + : ' today'}. Use them before they expire.
diff --git a/apps/frontend/src/utils/credits.ts b/apps/frontend/src/utils/credits.ts index 3a82d159c..a2616dcf5 100644 --- a/apps/frontend/src/utils/credits.ts +++ b/apps/frontend/src/utils/credits.ts @@ -23,8 +23,9 @@ export const isPackageOverCap = ( */ export const daysUntilExpiry = (expiresAt: Date | null): number | null => { if (!expiresAt) return null; - return Math.ceil( - (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + return Math.max( + 0, + Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)), ); }; From fb2fe4adddddb46455c34d203aa5de077e4539c9 Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 16:46:54 -0400 Subject: [PATCH 11/78] fix(tests): align daysUntilExpiry test with Math.max(0) clamp The function clamps to non-negative via Math.max(0, ...), so a past expiry date returns 0, not a negative number. The test expectation was toBeLessThan(0) which always fails. Made-with: Cursor --- apps/frontend/__tests__/unit/utils/credits.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts index 081fd194a..8d9977490 100644 --- a/apps/frontend/__tests__/unit/utils/credits.spec.ts +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -79,11 +79,11 @@ describe('daysUntilExpiry', () => { expect(daysUntilExpiry(expiresAt)).toBe(2) }) - it('returns a negative value for a past expiry date', () => { + it('returns 0 for a past expiry date (clamped)', () => { const now = new Date('2026-01-05T00:00:00Z') jest.setSystemTime(now) const expiresAt = new Date('2026-01-01T00:00:00Z') - expect(daysUntilExpiry(expiresAt)).toBeLessThan(0) + expect(daysUntilExpiry(expiresAt)).toBe(0) }) it('returns 30 when expiry is exactly 30 days away', () => { From d9e64ab0550346f6c6c1646f52bf6cd1c615af25 Mon Sep 17 00:00:00 2001 From: Emil F Date: Mon, 16 Mar 2026 18:51:52 -0400 Subject: [PATCH 12/78] fix(frontend): exclude creditSummary from Zustand localStorage persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credit data changes with every purchase and expiry tick, so persisting it caused a stale-data flash on page load — briefly showing an incorrect "Credit cap reached" banner or enabling packages that exceed the actual cap. Made-with: Cursor --- apps/frontend/src/globalStates/user.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/frontend/src/globalStates/user.ts b/apps/frontend/src/globalStates/user.ts index 006bb32b8..a9e160acd 100644 --- a/apps/frontend/src/globalStates/user.ts +++ b/apps/frontend/src/globalStates/user.ts @@ -38,6 +38,8 @@ export const useUserStore = create()( { name: 'user-dto-storage', version: 1, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + partialize: ({ creditSummary: _, ...rest }) => rest, }, ), ); From 1693de921084faf87dd4b72f59255f805e4fed73 Mon Sep 17 00:00:00 2001 From: Emil F Date: Wed, 18 Mar 2026 10:59:16 -0400 Subject: [PATCH 13/78] refactor(credits): scope purchased credits to uploads only - Purchase credits allocate upload bytes only (downloadBytesOriginal: 0n) - Cap check enforced on upload bytes only, download leg removed - maxPurchasableBytes derived from uploadBytesRemaining only - getPendingCreditsByAccountAndType skips purchased credits for downloads - registerInteraction skips consumeUpTo for downloads (fromPurchased = 0n) Download infrastructure (columns, repo methods, types) is preserved for future use but is not allocated or enforced at this stage. --- apps/backend/src/core/users/accounts.ts | 38 +++++++++++-------- apps/backend/src/core/users/credits.ts | 17 +++------ .../repositories/users/purchasedCredits.ts | 11 +++--- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/core/users/accounts.ts b/apps/backend/src/core/users/accounts.ts index b2d43f280..139e72ede 100644 --- a/apps/backend/src/core/users/accounts.ts +++ b/apps/backend/src/core/users/accounts.ts @@ -173,10 +173,14 @@ const getPendingCreditsByAccountAndType = async ( const freeRemaining = limit - spentCredits - // Also include any active purchased credits so the upload/download gate + // For uploads, also include any active purchased credits so the upload gate // (pendingCredits < metadata.totalSize) grants access when the user has // enough purchased bytes, even if their free allocation is exhausted. // + // Download credits are not enforced right now — infrastructure exists for + // future use but purchased download bytes are not allocated on purchase and + // are not counted here. + // // getRemainingCredits is a plain DB query with no awareness of the // buyCredits feature flag. If the flag is OFF and no rows exist in // purchased_credits (because the /intents routes are gated), this returns @@ -184,15 +188,14 @@ const getPendingCreditsByAccountAndType = async ( // If credits were purchased while the flag was ON and it is later turned // OFF, those already-purchased credits remain visible and usable — this is // intentional: users should not lose credits they already paid for. - const purchased = await purchasedCreditsRepository.getRemainingCredits( - account.id, - ) - const purchasedRemaining = - type === InteractionType.Upload - ? Number(purchased.uploadBytesRemaining) - : Number(purchased.downloadBytesRemaining) + if (type === InteractionType.Upload) { + const purchased = await purchasedCreditsRepository.getRemainingCredits( + account.id, + ) + return freeRemaining + Number(purchased.uploadBytesRemaining) + } - return freeRemaining + purchasedRemaining + return freeRemaining } const getAccountInfo = async ( @@ -263,11 +266,14 @@ const registerInteraction = async ( // avoids the TOCTOU race that existed with the previous two-phase approach // where releasing FOR UPDATE locks between calls allowed concurrent requests // to consume the same credits. - const fromPurchased = await purchasedCreditsRepository.consumeUpTo( - account.id, - creditType, - size, - ) + // + // Download credits are not enforced right now — purchased bytes are not + // allocated on purchase and are not consumed here. The consumeUpTo path + // and all compensation logic below is upload-only for now. + const fromPurchased = + type === InteractionType.Upload + ? await purchasedCreditsRepository.consumeUpTo(account.id, creditType, size) + : BigInt(0) const fromFree = size - fromPurchased @@ -406,7 +412,9 @@ const addCreditsToAccount = async ( accountId: account.id, intentId, uploadBytesOriginal: credits, - downloadBytesOriginal: credits, + // Download credits are not allocated on purchase — infrastructure is + // kept for future use but download limits are not enforced right now. + downloadBytesOriginal: 0n, expiresAt, }, config.credits.maxBytesPerUser, diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts index cb3d91ef1..8798da091 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -27,8 +27,8 @@ export type CreditSummary = { batchCount: number /** * True when the user can still make a purchase without exceeding the cap. - * Determined by taking the larger of upload/download remaining (both grow - * equally on each purchase) and checking it against maxBytesPerUser. + * Cap is enforced on upload bytes only — download credits are not allocated + * on purchase right now, so only uploadBytesRemaining is checked. */ canPurchase: boolean /** Maximum bytes the user could purchase right now without hitting the cap. */ @@ -45,15 +45,10 @@ const getSummary = async ( const cap = config.credits.maxBytesPerUser - // Each purchase adds the same number of bytes to both upload and download. - // The binding constraint is whichever type already has the most remaining - // — buying more would push that type over the cap first. - const maxConsumed = - summary.uploadBytesRemaining > summary.downloadBytesRemaining - ? summary.uploadBytesRemaining - : summary.downloadBytesRemaining - - const maxPurchasableBytes = cap > maxConsumed ? cap - maxConsumed : 0n + // Cap is enforced on upload bytes only. Download credits are not allocated + // on purchase right now so there is no download cap to check. + const maxPurchasableBytes = + cap > summary.uploadBytesRemaining ? cap - summary.uploadBytesRemaining : 0n const canPurchase = maxPurchasableBytes > 0n return { diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 825bec175..d85a2393f 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -466,7 +466,10 @@ const refundCredits = async ( // Atomically checks the per-user cap and inserts a new credit row in a // single transaction, using a PostgreSQL advisory lock keyed to the account // to serialize concurrent calls. Returns err('cap_exceeded') if the purchase -// would push either upload or download remaining over the cap. +// would push upload remaining over the cap. +// Download bytes are not capped — they are not allocated on purchase right +// now (downloadBytesOriginal is expected to be 0n). Infrastructure is kept +// for future use. // --------------------------------------------------------------------------- const createPurchasedCreditWithCapCheck = async ( @@ -504,12 +507,8 @@ const createPurchasedCreditWithCapCheck = async ( const currentRow = currentResult.rows[0] const currentUpload = BigInt(currentRow.upload_bytes_remaining) - const currentDownload = BigInt(currentRow.download_bytes_remaining) - if ( - currentUpload + params.uploadBytesOriginal > maxBytesPerUser || - currentDownload + params.downloadBytesOriginal > maxBytesPerUser - ) { + if (currentUpload + params.uploadBytesOriginal > maxBytesPerUser) { await client.query('ROLLBACK') return err('cap_exceeded' as const) } From 90a4b960833a7f666717e69a3df4b9f443b7125e Mon Sep 17 00:00:00 2001 From: Emil F Date: Wed, 18 Mar 2026 11:36:50 -0400 Subject: [PATCH 14/78] test(credits): update unit tests to reflect upload-only cap --- apps/backend/__tests__/unit/useCases/accounts.spec.ts | 3 ++- apps/backend/__tests__/unit/useCases/credits.spec.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/backend/__tests__/unit/useCases/accounts.spec.ts b/apps/backend/__tests__/unit/useCases/accounts.spec.ts index bdb31f813..f514c797a 100644 --- a/apps/backend/__tests__/unit/useCases/accounts.spec.ts +++ b/apps/backend/__tests__/unit/useCases/accounts.spec.ts @@ -273,7 +273,8 @@ describe('AccountsUseCases', () => { accountId: 'acc123', intentId: 'intent-xyz', uploadBytesOriginal: BigInt(500), - downloadBytesOriginal: BigInt(500), + // Download credits are not allocated on purchase + downloadBytesOriginal: 0n, }), expect.anything(), ) diff --git a/apps/backend/__tests__/unit/useCases/credits.spec.ts b/apps/backend/__tests__/unit/useCases/credits.spec.ts index 99a7684a0..3a57ca84b 100644 --- a/apps/backend/__tests__/unit/useCases/credits.spec.ts +++ b/apps/backend/__tests__/unit/useCases/credits.spec.ts @@ -184,9 +184,9 @@ describe('CreditsUseCases', () => { expect(summary.maxPurchasableBytes).toBe(0n) }) - it('handles asymmetric remaining bytes (download higher than upload)', async () => { + it('uses upload bytes only for cap even when download remaining is higher', async () => { const uploadRemaining = BigInt(30 * 1024 ** 3) - const downloadRemaining = BigInt(70 * 1024 ** 3) // download is binding + const downloadRemaining = BigInt(70 * 1024 ** 3) const cap = config.credits.maxBytesPerUser jest @@ -200,8 +200,9 @@ describe('CreditsUseCases', () => { const summary = await CreditsUseCases.getSummary(baseUser) - // Download (70 GiB) is the binding constraint - expect(summary.maxPurchasableBytes).toBe(cap - downloadRemaining) + // Cap is upload-only — download bytes are not allocated on purchase + // and do not factor into maxPurchasableBytes. + expect(summary.maxPurchasableBytes).toBe(cap - uploadRemaining) expect(summary.canPurchase).toBe(true) }) From 764c7e6a06cd5c5904a1b2d70588e54a8c6e1d2e Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 01:40:29 +0300 Subject: [PATCH 15/78] feat(ui): show purchased credits in AccountInformation sidebar widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AccountInformation component previously only rendered the free-tier upload progress bar. Users who purchased credits saw no indication of their purchased storage in the sidebar — the bar only reflected free-tier consumption. Changes: - Add optional `purchasedBytesRemaining?: number` and `nextExpiryDate?: Date | null` props (both default to safe values so all existing call-sites continue to work without modification). - When `purchasedBytesRemaining > 0`, render a compact "Purchased credits" row below the free-tier bar showing the remaining bytes and, if a next-expiry date is available, a relative-time hint ("expires in 2 months"). - The purchased-credits section is invisible to every other account type: Monthly accounts, free-only OneOff accounts, and users whose operator has not enabled the buyCredits feature flag all see zero change. Co-Authored-By: Claude Sonnet 4.6 --- .../molecules/AccountInformation/index.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/frontend/src/components/molecules/AccountInformation/index.tsx b/apps/frontend/src/components/molecules/AccountInformation/index.tsx index fde765112..7f49248b0 100644 --- a/apps/frontend/src/components/molecules/AccountInformation/index.tsx +++ b/apps/frontend/src/components/molecules/AccountInformation/index.tsx @@ -7,6 +7,17 @@ interface CreditLimitsProps { uploadLimit: number; renewalDate: Date; model: AccountModel; + /** + * Bytes remaining across all active purchased-credit rows. + * Only passed when the buyCredits feature flag is active for this user. + * Defaults to 0 (no purchased credits section rendered). + */ + purchasedBytesRemaining?: number; + /** + * Soonest expiry date across the user's active purchased-credit rows. + * Shown as a hint when the purchased-credits section is rendered. + */ + nextExpiryDate?: Date | null; } export const AccountInformation = ({ @@ -14,6 +25,8 @@ export const AccountInformation = ({ uploadPending = 0, uploadLimit = 1000, renewalDate, + purchasedBytesRemaining = 0, + nextExpiryDate = null, }: CreditLimitsProps) => { const uploadUsed = uploadLimit - uploadPending; @@ -22,6 +35,8 @@ export const AccountInformation = ({ Math.min(100, (uploadUsed / uploadLimit) * 100), ); + const hasPurchasedCredits = purchasedBytesRemaining > 0; + return (
Upload usage
@@ -44,6 +59,25 @@ export const AccountInformation = ({ Renews in {utcToLocalRelativeTime(renewalDate.toISOString())}

)} + + {/* Purchased credits — only rendered when the user has active purchased + credits (i.e. hasBuyCreditsFeature AND uploadBytesRemaining > 0). + Invisible to all other account types and feature-flag states. */} + {hasPurchasedCredits && ( +
+
Purchased credits
+
+ + {formatBytes(purchasedBytesRemaining, 2)} + + {nextExpiryDate && ( + + expires {utcToLocalRelativeTime(nextExpiryDate.toISOString())} + + )} +
+
+ )}
); }; From 2d94fc50886b98aea4e2b767b21525ca5a091908 Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 01:41:02 +0300 Subject: [PATCH 16/78] feat(sidebar): thread creditSummary into AccountInformation widget SideNavbar now reads creditSummary from the Zustand store (populated by SessionEnsurer's 30-second GET /credits/summary poll) and passes purchasedBytesRemaining and nextExpiryDate to AccountInformation. Guard conditions ensure the purchased-credits section only appears when ALL of the following are true, matching the existing buyCredits gate: - features.buyCredits feature flag is enabled by the operator - user is logged in - account model is OneOff (not Monthly) - creditSummary has loaded and uploadBytesRemaining > 0 Monthly accounts, unauthenticated users, free-tier-only OneOff accounts, and any deployment where buyCredits is disabled are completely unaffected. nextExpiryDate is parsed from the API's ISO string only when a non-null value is present, so null/undefined propagates safely. Co-Authored-By: Claude Sonnet 4.6 --- .../components/organisms/SideNavBar/index.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/organisms/SideNavBar/index.tsx b/apps/frontend/src/components/organisms/SideNavBar/index.tsx index a120435c1..26bde959a 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/index.tsx +++ b/apps/frontend/src/components/organisms/SideNavBar/index.tsx @@ -24,7 +24,7 @@ export type SideNavbarProps = { export const SideNavbar = ({ networkId }: SideNavbarProps) => { const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); - const { user, account, features } = useUserStore(); + const { user, account, features, creditSummary } = useUserStore(); const { state } = useSidebar(); const session = useContext(SessionContext); @@ -53,6 +53,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. + 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. + return Number(creditSummary.uploadBytesRemaining); + }, [hasBuyCreditsFeature, creditSummary]); + + const nextExpiryDate = useMemo(() => { + if (!hasBuyCreditsFeature || !creditSummary?.nextExpiryDate) return null; + return new Date(creditSummary.nextExpiryDate); + }, [hasBuyCreditsFeature, creditSummary]); + return ( { renewalDate={renewalDate} uploadLimit={account?.uploadLimit ?? 0} uploadPending={account?.pendingUploadCredits ?? 0} + purchasedBytesRemaining={purchasedBytesRemaining} + nextExpiryDate={nextExpiryDate} /> )} {isLoggedIn && account ? ( From 5d3c007bf250d3008b7e976de52b43dfed89914d Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 01:41:30 +0300 Subject: [PATCH 17/78] fix(purchase): correct balance and after-purchase total in Step2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the "Current Credits Balance" row read account.pendingUploadCredits which is the free-tier remaining quota. This was wrong for the purchase flow: 1. Free-tier credits and purchased credits are separate pools — showing the free-tier balance when a user is about to buy more purchased credits is misleading. 2. The "After Purchase" row showed only the new purchase size (sizeMB), not the total purchased credits the user will have after the transaction. Changes: - "Current Credits Balance" → "Current Purchased Credits", reading creditSummary.uploadBytesRemaining (the purchased pool) and safely defaulting to 0 while the summary is loading or for users who have never purchased. - "After Purchase" now computes currentPurchasedBytes + new purchase bytes so the user sees their real total purchased capacity post-transaction. - sizeMB is in MiB so the conversion is sizeMB × 1024 × 1024 bytes. Free-tier users (creditSummary.uploadBytesRemaining === "0") see "Current Purchased Credits: 0 B" and "After Purchase: X MiB" which is correct — they currently have no purchased credits and will have X MiB after this purchase. Co-Authored-By: Claude Sonnet 4.6 --- .../steps/Step2_ConfirmPurchase.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 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 911b867e4..d49bb62c0 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step2_ConfirmPurchase.tsx @@ -33,7 +33,15 @@ export const PurchaseStep2ConnectWallet = ({ const isCustom = String(context.packageId ?? 'custom') === 'custom'; - const uploadPending = useUserStore((u) => u.account?.pendingUploadCredits); + // 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 { title, sizeMB } = useMemo(() => { const id = String(context.packageId ?? 'custom'); @@ -175,13 +183,20 @@ export const PurchaseStep2ConnectWallet = ({
{formatBytes(uploadPending ?? 0, 2)}} + value={{formatBytes(currentPurchasedBytes, 2)}} /> {sizeMB}MiB} + value={ + + {formatBytes( + currentPurchasedBytes + Number(sizeMB) * 1024 * 1024, + 2, + )} + + } className='rounded-md bg-primary/20 p-4' accent /> From 51778ad5b339cf6e326b1e1eb15c6f90389ed610 Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 15:23:17 +0300 Subject: [PATCH 18/78] feat(purchase): show new purchased credits total on Step4 success screen After a successful payment the user only saw "Credits Added: X MiB" with no indication of their cumulative purchased storage balance. Step4 now reads creditSummary.uploadBytesRemaining from the Zustand store. By the time Step4 renders the intent is in 'completed' state and useTransactionConfirmation has already invalidated the creditSummary query, so the store holds the refreshed post-purchase balance. "New Purchased Credits Total" is rendered only when the balance is loaded and greater than zero, which means: - The row is invisible until the query has resolved (no flash of 0 B). - Edge cases where creditSummary is null (e.g. query not yet settled) simply show nothing rather than a misleading zero. - Free-tier-only users who somehow reach this page are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- .../PurchaseCredits/steps/Step4_Success.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 08c90d55f..b0a50fb1d 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step4_Success.tsx @@ -6,6 +6,8 @@ import { Section } from '../atoms/Section'; import { usePrices } from '../../../../hooks/usePrices'; import { shortenString } from '../../../../utils/misc'; import { CopiableText } from '../../../atoms/CopiableText'; +import { useUserStore } from '../../../../globalStates/user'; +import { formatBytes } from '../../../../utils/number'; export const PurchaseStep4Success = ({ context, @@ -16,6 +18,17 @@ export const PurchaseStep4Success = ({ const sizeMB = context.sizeMB as number; + // creditSummary is invalidated by useTransactionConfirmation once the + // backend marks the intent as completed, so by the time Step 4 renders + // the store should already hold the updated balance. + // We show it only when it has loaded and is non-zero to avoid showing + // "0 B" to free-tier users who somehow reach this page edge-case. + const newPurchasedBalance = useUserStore((s) => { + if (!s.creditSummary) return null; + const bytes = Number(s.creditSummary.uploadBytesRemaining); + return bytes > 0 ? bytes : null; + }); + return (
@@ -67,6 +80,16 @@ export const PurchaseStep4Success = ({ } /> + {newPurchasedBalance !== null && ( + + {formatBytes(newPurchasedBalance, 2)} + + } + /> + )}
From 7e67b552df00ce370b2c7215a2fe302000b46a6f Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 16:11:10 +0300 Subject: [PATCH 19/78] fix(uploads): add credit guard to folder upload finalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder upload finalisation path (handleFolderUploadFinalization) had no credit check whatsoever, allowing any user — including those with a zero or negative balance — to complete a folder DAG structure without any account validation. This commit adds a guard-only check that reads the current pending credit balance and throws if it has somehow gone negative, surfacing account inconsistencies early rather than silently producing a broken folder object. Why registerInteraction is NOT called here: Each child file upload is independently finalised via handleFileUploadFinalization, which calls registerInteraction and deducts the file's content bytes from the user's credit pool before this function runs. The folder root IPLD node carries no independent byte cost beyond its children: metadata.totalSize = sum(children[i].totalSize), so calling registerInteraction(metadata.totalSize) would double-charge the user for all folder content. The guard-only check preserves the correct economics while adding the missing account validation. Co-Authored-By: Claude Sonnet 4.6 --- .../src/core/uploads/uploadProcessing.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/backend/src/core/uploads/uploadProcessing.ts b/apps/backend/src/core/uploads/uploadProcessing.ts index 6858b7e06..fb8f22c59 100644 --- a/apps/backend/src/core/uploads/uploadProcessing.ts +++ b/apps/backend/src/core/uploads/uploadProcessing.ts @@ -227,6 +227,32 @@ const handleFolderUploadFinalization = async ( uploadId, user.oauthUserId, ) + + // Credit guard: verify the user has a non-negative credit balance before + // finalising the folder DAG structure. + // + // NOTE: Each child file upload is individually finalised via + // handleFileUploadFinalization, which calls registerInteraction and deducts + // the file's bytes from the user's credit pool. By the time this function + // runs, all child credit deductions have already been committed. + // + // The folder root itself is a small IPLD directory node whose + // metadata.totalSize equals the SUM of its children's totalSize values — it + // carries no independent byte cost beyond what the children already paid for. + // Calling registerInteraction(metadata.totalSize) here would therefore + // double-charge the user for all folder content, which is incorrect. + // + // Instead we perform a guard-only check: if the balance has somehow gone + // negative (which should never happen under normal operation) we surface the + // error early rather than silently producing an inconsistent folder object. + const pendingCredits = await AccountsUseCases.getPendingCreditsByUserAndType( + user, + InteractionType.Upload, + ) + if (pendingCredits < 0) { + throw new Error('Insufficient upload credits') + } + const { metadata, childrenArtifacts } = await UploadArtifactsUseCase.generateFolderArtifacts(uploadId) From 32d417e29db2211679d433b7b0bf6ed7f18e5b44 Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 16:16:50 +0300 Subject: [PATCH 20/78] feat(api): add getCreditBatches() for full purchase history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new getCreditBatches() method that calls GET /credits/batches, returning the complete purchase history for the authenticated user (newest-first, including expired rows). Reuses the existing ExpiringCreditBatch wire type — the serialisation shape is identical for both endpoints. Co-Authored-By: Claude Sonnet 4.6 --- apps/frontend/src/services/api.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 79813aef5..91a4e0892 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -496,6 +496,25 @@ export const createApiService = ({ return response.json() as Promise; }, + getCreditBatches: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/credits/batches`, { + 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; + }, getExpiringCreditBatches: async (): Promise => { const session = await getAuthSession(); if (!session?.authProvider || !session.accessToken) { From fa6703fd93e011303016a2449de748327d3d01b9 Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 16:17:00 +0300 Subject: [PATCH 21/78] feat(credits): add /drive/credits purchase history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the CreditHistory view and its Next.js route (/[chain]/drive/credits). The page is wrapped in UserProtectedLayout so only authenticated users can access it. The view: - Lists all purchased credit batches (GET /credits/batches), newest- first, using the existing ExpiringCreditBatch wire type - Shows a status badge per batch: Active / Expiring soon / Depleted / Expired - Renders a consumption progress bar per batch showing how many bytes have been used out of the original purchase - Shows a "Buy more credits" CTA when the user has no active batches or all non-expired batches expire within 7 days - Guarded by hasBuyCreditsFeature (features.buyCredits && OneOff model) — non-qualifying users see a polite "not available" message Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/[chain]/drive/credits/page.tsx | 10 + .../components/views/CreditHistory/index.tsx | 238 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 apps/frontend/src/app/[chain]/drive/credits/page.tsx create mode 100644 apps/frontend/src/components/views/CreditHistory/index.tsx diff --git a/apps/frontend/src/app/[chain]/drive/credits/page.tsx b/apps/frontend/src/app/[chain]/drive/credits/page.tsx new file mode 100644 index 000000000..0cc85743f --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/credits/page.tsx @@ -0,0 +1,10 @@ +import { CreditHistoryView } from '@/components/views/CreditHistory'; +import { UserProtectedLayout } from '../../../../components/layouts/UserProtectedLayout'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/components/views/CreditHistory/index.tsx b/apps/frontend/src/components/views/CreditHistory/index.tsx new file mode 100644 index 000000000..ea65f2934 --- /dev/null +++ b/apps/frontend/src/components/views/CreditHistory/index.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useNetwork } from '../../../contexts/network'; +import { useUserStore } from '../../../globalStates/user'; +import { AccountModel } from '@auto-drive/models'; +import { formatBytes } from '../../../utils/number'; +import { formatDate } from '../../../utils/time'; +import { daysUntilExpiry } from '../../../utils/credits'; +import Link from 'next/link'; +import { Button } from '@auto-drive/ui'; +import { ShoppingCart, RefreshCw } from 'lucide-react'; +import { useMemo } from 'react'; +import type { ExpiringCreditBatch } from '../../../services/api'; + +// --------------------------------------------------------------------------- +// Batch status helpers +// --------------------------------------------------------------------------- + +type BatchStatus = 'active' | 'expiring' | 'depleted' | 'expired'; + +const getBatchStatus = (batch: ExpiringCreditBatch): BatchStatus => { + if (batch.expired) return 'expired'; + if (BigInt(batch.uploadBytesRemaining) === BigInt(0)) return 'depleted'; + const days = daysUntilExpiry(new Date(batch.expiresAt)); + if (days !== null && days <= 30) return 'expiring'; + return 'active'; +}; + +const STATUS_LABEL: Record = { + active: 'Active', + expiring: 'Expiring soon', + depleted: 'Depleted', + expired: 'Expired', +}; + +const STATUS_CLASSES: Record = { + active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + expiring: + 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + depleted: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + expired: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +// --------------------------------------------------------------------------- +// Consumption progress bar +// --------------------------------------------------------------------------- + +const ConsumptionBar = ({ batch }: { batch: ExpiringCreditBatch }) => { + const original = Number(BigInt(batch.uploadBytesOriginal)); + const remaining = Number(BigInt(batch.uploadBytesRemaining)); + const consumed = original - remaining; + const pct = original > 0 ? Math.round((consumed / original) * 100) : 0; + + return ( +
+
+
+
+

+ {formatBytes(consumed, 1)} used of {formatBytes(original, 1)} ({pct}%) +

+
+ ); +}; + +// --------------------------------------------------------------------------- +// Batch card +// --------------------------------------------------------------------------- + +const BatchCard = ({ batch }: { batch: ExpiringCreditBatch }) => { + const status = getBatchStatus(batch); + const daysLeft = + !batch.expired ? daysUntilExpiry(new Date(batch.expiresAt)) : null; + + return ( +
+
+
+
+ + {STATUS_LABEL[status]} + + {status === 'expiring' && daysLeft !== null && ( + + {daysLeft === 0 ? 'expires today' : `${daysLeft}d remaining`} + + )} +
+ + +
+ +
+

+ {formatBytes(Number(BigInt(batch.uploadBytesRemaining)), 1)}{' '} + remaining +

+
+
+ +
+ Purchased + {formatDate(batch.purchasedAt)} + Expires + + {batch.expired ? ( + + Expired {formatDate(batch.expiresAt)} + + ) : ( + formatDate(batch.expiresAt) + )} + +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// CTA panel — shown when all credits are depleted / expiring soon +// --------------------------------------------------------------------------- + +const BuyMoreCta = ({ purchaseHref }: { purchaseHref: string }) => ( +
+
+
+

Need more storage?

+

+ Purchase additional credits to keep uploading to the Autonomys + Network. +

+
+ + + +
+
+); + +// --------------------------------------------------------------------------- +// Main view +// --------------------------------------------------------------------------- + +export const CreditHistoryView = () => { + const { api, network } = useNetwork(); + const { account, features } = useUserStore(); + + const hasBuyCreditsFeature = + features.buyCredits && account?.model === AccountModel.OneOff; + + const purchaseHref = `/${network.id}/drive/purchase`; + + const { data: batches = [], isLoading } = useQuery({ + queryKey: ['creditBatches'], + queryFn: () => api.getCreditBatches(), + enabled: hasBuyCreditsFeature, + staleTime: 30_000, + }); + + // Show CTA when user has no active non-depleted batches or all are expiring + // within 7 days. + const showBuyMoreCta = useMemo(() => { + if (!hasBuyCreditsFeature) return false; + if (batches.length === 0) return true; + const activeBatches = batches.filter((b: ExpiringCreditBatch) => { + if (b.expired) return false; + if (BigInt(b.uploadBytesRemaining) === BigInt(0)) return false; + const days = daysUntilExpiry(new Date(b.expiresAt)); + return days === null || days > 7; + }); + return activeBatches.length === 0; + }, [batches, hasBuyCreditsFeature]); + + if (!hasBuyCreditsFeature) { + return ( +
+

+ Credit History +

+

+ Purchased credit history is only available for pay-as-you-go accounts. +

+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ Credit History +

+ {hasBuyCreditsFeature && ( + + + + )} +
+ + {/* Buy more CTA */} + {showBuyMoreCta && } + + {/* Batch list */} + {isLoading ? ( +
+ + Loading credit history… +
+ ) : batches.length === 0 ? ( +
+

No purchases yet.

+

+ Credit batches will appear here once you complete a purchase. +

+
+ ) : ( +
+ {batches.map((batch: ExpiringCreditBatch) => ( + + ))} +
+ )} +
+ ); +}; From 8c01767848761f6cc2ed55c41439c627df88a4fb Mon Sep 17 00:00:00 2001 From: Emil F Date: Thu, 19 Mar 2026 16:17:10 +0300 Subject: [PATCH 22/78] =?UTF-8?q?feat(sidebar):=20add=20'View=20history=20?= =?UTF-8?q?=E2=86=92'=20link=20to=20purchased=20credits=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the buyCredits feature is active and the user has a non-zero purchased credits balance, a 'View history →' link now appears below the balance/expiry line in the AccountInformation sidebar widget, linking to the new /[chain]/drive/credits history page. Changes: - AccountInformation: adds optional creditHistoryHref prop; renders a Next.js Link when the prop is present and purchasedBytesRemaining > 0 - SideNavBar: derives creditHistoryHref = /${networkId}/drive/credits when hasBuyCreditsFeature; passes it to AccountInformation All existing callers of AccountInformation are unaffected (the new prop is optional and defaults to undefined). Co-Authored-By: Claude Sonnet 4.6 --- .../molecules/AccountInformation/index.tsx | 16 ++++++++++++++++ .../components/organisms/SideNavBar/index.tsx | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/apps/frontend/src/components/molecules/AccountInformation/index.tsx b/apps/frontend/src/components/molecules/AccountInformation/index.tsx index 7f49248b0..706c0e7e8 100644 --- a/apps/frontend/src/components/molecules/AccountInformation/index.tsx +++ b/apps/frontend/src/components/molecules/AccountInformation/index.tsx @@ -1,6 +1,7 @@ import { utcToLocalRelativeTime } from '../../../utils/time'; import { AccountModel } from '@auto-drive/models'; import { formatBytes } from '../../../utils/number'; +import Link from 'next/link'; interface CreditLimitsProps { uploadPending: number; @@ -18,6 +19,12 @@ interface CreditLimitsProps { * Shown as a hint when the purchased-credits section is rendered. */ nextExpiryDate?: Date | null; + /** + * href to the credit history page. When provided, a "View history" link is + * shown below the purchased credits row. Only passed when the buyCredits + * feature flag is active. + */ + creditHistoryHref?: string; } export const AccountInformation = ({ @@ -27,6 +34,7 @@ export const AccountInformation = ({ renewalDate, purchasedBytesRemaining = 0, nextExpiryDate = null, + creditHistoryHref, }: CreditLimitsProps) => { const uploadUsed = uploadLimit - uploadPending; @@ -76,6 +84,14 @@ export const AccountInformation = ({ )}
+ {creditHistoryHref && ( + + View history → + + )}
)}
diff --git a/apps/frontend/src/components/organisms/SideNavBar/index.tsx b/apps/frontend/src/components/organisms/SideNavBar/index.tsx index 26bde959a..7d2fdfaa9 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/index.tsx +++ b/apps/frontend/src/components/organisms/SideNavBar/index.tsx @@ -69,6 +69,10 @@ export const SideNavbar = ({ networkId }: SideNavbarProps) => { return new Date(creditSummary.nextExpiryDate); }, [hasBuyCreditsFeature, creditSummary]); + const creditHistoryHref = hasBuyCreditsFeature + ? `/${networkId}/drive/credits` + : undefined; + return ( { uploadPending={account?.pendingUploadCredits ?? 0} purchasedBytesRemaining={purchasedBytesRemaining} nextExpiryDate={nextExpiryDate} + creditHistoryHref={creditHistoryHref} /> )} {isLoggedIn && account ? ( From efa409a3357703bf5a4ada01cdf20ddc2cda0a98 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 13:04:40 +0400 Subject: [PATCH 23/78] fix(intents): add idempotency guard to markIntentAsConfirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markIntentAsConfirmed previously performed an unconditional UPDATE, meaning a duplicate call could overwrite an intent that was already CONFIRMED, COMPLETED, or OVER_CAP — resetting it back to CONFIRMED and potentially triggering a second credit grant on the next polling tick. Duplicate calls arise legitimately from: • chain reorganisations causing the same IntentPaymentReceived event to be re-emitted • the payment manager reconnecting after downtime and re-processing event logs it already handled • watchTransaction and the _checkConfirmedIntents polling fallback racing each other on the same intent The fix reads the current status before writing. If the intent is already past the PENDING stage (CONFIRMED, COMPLETED, or OVER_CAP) the function returns ok() without touching the row, so the caller does not treat a duplicate as a failure and does not schedule a retry. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/core/users/intents.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index 2474062a9..f6ae980d1 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -155,8 +155,28 @@ const markIntentAsConfirmed = async ({ return err(new ObjectNotFoundError('Intent not found')) } + // Idempotency guard — do not overwrite an intent that is already in a + // post-PENDING state. Duplicate calls arise from: + // • chain reorgs causing the same event to be re-emitted + // • the payment manager reconnecting and re-processing already-seen logs + // • watchTransaction and the _checkConfirmedIntents polling loop racing + // + // We return ok() rather than an error so the caller does not treat a + // duplicate as a failure and does not retry indefinitely. + if ( + intent.status === IntentStatus.CONFIRMED || + intent.status === IntentStatus.COMPLETED || + intent.status === IntentStatus.OVER_CAP + ) { + logger.info('markIntentAsConfirmed: intent already processed — skipping', { + intentId, + currentStatus: intent.status, + }) + return ok(intent) + } + return ok( - intentsRepository.updateIntent({ + await intentsRepository.updateIntent({ ...intent, status: IntentStatus.CONFIRMED, paymentAmount, From e42e02c6b89153c83875fbe2f0ae6063d7d92227 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 13:05:21 +0400 Subject: [PATCH 24/78] fix(intents): guard against dust payments yielding zero credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getIntentCredits() divides paymentAmount by shannonsPerByte using BigInt integer division. A payment smaller than one shannonsPerByte produces 0 credits. Previously onConfirmedIntent would call addCreditsToAccount(0), mark the intent COMPLETED, and give the user nothing — a misleading outcome that silently discards the payment and wastes a DB row. The fix computes creditBytes before calling addCreditsToAccount and returns an error if the result is zero. The polling loop will retry on the next tick, giving operators visibility through the error log rather than a silent no-op COMPLETED status. In normal operation this guard should never fire: the frontend enforces a minimum package size well above one byte. It defends against buggy clients or unexpected future changes to the shannonsPerByte price. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/core/users/intents.ts | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index f6ae980d1..87771b390 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -209,9 +209,34 @@ const onConfirmedIntent = async (intentId: string) => { return err(new Error('Intent has no deposit amount')) } + // Guard: reject payments whose value is too small to purchase even a single + // byte of storage. getIntentCredits divides paymentAmount by shannonsPerByte + // using BigInt integer division, so a dust payment (paymentAmount < + // shannonsPerByte) yields 0 credits. Granting 0 credits would mark the + // intent COMPLETED while giving the user nothing — a misleading outcome that + // wastes a DB row and silently discards the payment. + // + // Instead we log a warning and return an error so the polling loop retries + // (in case of a transient pricing mismatch) and operators are alerted. + // In practice this should never happen because the frontend enforces a + // minimum package size, but the guard defends against buggy clients or + // future contract changes. + const creditBytes = IntentsUseCases.getIntentCredits(intent) + if (creditBytes === BigInt(0)) { + logger.warn( + 'onConfirmedIntent: payment too small to yield any credits — skipping', + { + intentId, + paymentAmount: intent.paymentAmount.toString(), + shannonsPerByte: intent.shannonsPerByte.toString(), + }, + ) + return err(new Error('Payment amount yields zero credits')) + } + const addResult = await AccountsUseCases.addCreditsToAccount( intent.userPublicId, - IntentsUseCases.getIntentCredits(intent), + creditBytes, intentId, ) From 82ef1d1988157e27a01e052f9cd9026b824ff3bd Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 13:07:05 +0400 Subject: [PATCH 25/78] fix(paymentManager): recover orphaned PENDING+txHash intents on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the payment manager restarted (or the EVM RPC was unavailable) while a user's on-chain transaction was in flight, the intent stays PENDING with its tx_hash set. The cleanup job correctly skips these rows (a PENDING+txHash intent is not abandoned — it is being watched), but the payment manager's polling loop only queries CONFIRMED intents, so the transaction is never re-processed. The user has paid on-chain but receives no credits. This commit adds a startup recovery sweep: intentsRepository: new getPendingWithTxHash() query — selects PENDING intents with a non-null tx_hash. IntentsUseCases: exposes getPendingWithTxHash() on the use-case layer. paymentManager._recoverOrphanedTransactions(): on startup, fetches all PENDING+txHash intents and calls watchTransaction() for each one. waitForTransactionReceipt() returns immediately for already-mined transactions, so the sweep completes quickly in the happy path. Errors are caught per-intent with Promise.allSettled() so a single bad tx does not abort recovery of the others. paymentManager.start(): fires the recovery sweep asynchronously (non-blocking) before starting the polling interval and event watcher. Safety: markIntentAsConfirmed is now idempotent (previous commit), so a sweep that re-discovers an already-CONFIRMED intent is a no-op. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/core/users/intents.ts | 8 +++ .../repositories/users/intents.ts | 18 +++++++ .../services/paymentManager/index.ts | 49 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index 87771b390..07930d65c 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -371,6 +371,13 @@ const getPrice = async (): Promise<{ price: number; pricePerGB: number }> => { } } +// Returns PENDING intents that already have a tx_hash — used by the payment +// manager startup sweep to re-watch transactions that were submitted but never +// confirmed due to a service restart or RPC outage. +const getPendingWithTxHash = async (): Promise => { + return intentsRepository.getPendingWithTxHash() +} + export const IntentsUseCases = { createIntent, getIntent, @@ -380,6 +387,7 @@ export const IntentsUseCases = { markIntentAsConfirmed, getConfirmedIntents, getOverCapIntents, + getPendingWithTxHash, reprocessOverCapIntent, getIntentCredits, getPrice, diff --git a/apps/backend/src/infrastructure/repositories/users/intents.ts b/apps/backend/src/infrastructure/repositories/users/intents.ts index a7d7a2475..8874cd0b1 100644 --- a/apps/backend/src/infrastructure/repositories/users/intents.ts +++ b/apps/backend/src/infrastructure/repositories/users/intents.ts @@ -117,6 +117,23 @@ const expireIntentIfPending = async (intentId: string): Promise => { return (result.rowCount ?? 0) > 0 } +// Returns PENDING intents that already have an on-chain tx_hash. +// These are intents where the user submitted a transaction but the payment +// manager did not process the confirmation event — typically because the +// service was restarted or the EVM RPC was temporarily unavailable. +// Used by the startup recovery sweep so that no paid transaction is silently +// abandoned across a service restart. +const getPendingWithTxHash = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM intents + WHERE status = $1 + AND tx_hash IS NOT NULL`, + [IntentStatus.PENDING], + ) + return mapRows(result.rows) +} + // Returns all intents that were blocked by the per-user cap. // These are terminal — the polling loop skips them — and require admin review. const getOverCapIntents = async (): Promise => { @@ -136,4 +153,5 @@ export const intentsRepository = { getExpiredPendingIntents, expireIntentIfPending, getOverCapIntents, + getPendingWithTxHash, } diff --git a/apps/backend/src/infrastructure/services/paymentManager/index.ts b/apps/backend/src/infrastructure/services/paymentManager/index.ts index 9bc2c9653..0cce7f9d8 100644 --- a/apps/backend/src/infrastructure/services/paymentManager/index.ts +++ b/apps/backend/src/infrastructure/services/paymentManager/index.ts @@ -122,8 +122,56 @@ const parseEventLogs = < let checkInterval: NodeJS.Timeout | null = null let unwatchContractEvent: (() => void) | null = null +// On startup, re-watch any PENDING intents that already have a tx_hash. +// These represent transactions submitted by users before the last service +// restart or during an EVM RPC outage. The cleanup job explicitly skips +// PENDING+txHash rows (they are not abandoned — they are actively watched), +// so without this sweep they would sit in limbo indefinitely: the user paid +// on-chain but receives no credits. +// +// Re-calling watchTransaction for each orphan is safe: +// • waitForTransactionReceipt returns immediately for already-mined txs +// • markIntentAsConfirmed is idempotent — a duplicate CONFIRMED write is a +// no-op if the intent was already processed before the restart +const _recoverOrphanedTransactions = async () => { + const pending = await IntentsUseCases.getPendingWithTxHash() + if (pending.length === 0) { + logger.info('Startup recovery: no orphaned transactions found') + return + } + + logger.info('Startup recovery: re-watching orphaned transactions', { + count: pending.length, + intentIds: pending.map((i) => i.id), + }) + + await Promise.allSettled( + pending.map(async (intent) => { + if (!intent.txHash) return + try { + await paymentManager.watchTransaction(intent.txHash) + logger.info('Startup recovery: transaction recovered', { + intentId: intent.id, + txHash: intent.txHash, + }) + } catch (err) { + logger.error('Startup recovery: failed to recover transaction', { + intentId: intent.id, + txHash: intent.txHash, + err, + }) + } + }), + ) +} + const start = () => { logger.info('Starting payment manager') + + // Run the recovery sweep asynchronously so it does not block startup. + // Errors inside the sweep are caught per-intent and logged individually. + safeCallback(paymentManager._recoverOrphanedTransactions)() + checkInterval = setInterval( safeCallback(paymentManager._checkConfirmedIntents), config.paymentManager.checkInterval, @@ -154,6 +202,7 @@ export const paymentManager = { stop, _onLogs: onLogs, _checkConfirmedIntents: _checkConfirmedIntents, + _recoverOrphanedTransactions: _recoverOrphanedTransactions, _viemClient: viemClient, _parseEventLogs: parseEventLogs, } From 9cda570e8474620438f79555e844dfabbdf658e3 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 13:07:44 +0400 Subject: [PATCH 26/78] docs(env): document all pay-with-AI3 env vars in .env.sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purchased-credits feature introduced 10 environment variables that were absent from .env.sample, making it impossible for a new operator to configure the payment pipeline without reading config.ts directly. Added with defaults and descriptions: BUY_CREDITS_ACTIVE — master feature flag (default: false) BUY_CREDITS_STAFF_ONLY — staged-rollout flag (default: false) EVM_CHAIN_ENDPOINT — Auto-EVM RPC URL EVM_CHAIN_CONTRACT_ADDRESS — AutoDriveCreditsReceiver address EVM_CHAIN_CONFIRMATIONS — block confirmations before finality (6) EVM_CHAIN_CHECK_INTERVAL — polling fallback interval ms (30 000) CREDITS_PRICE_MULTIPLIER — markup on consensus byte fee (5.00) CREDIT_EXPIRY_DAYS — days until a credit batch expires (90) MAX_CREDITS_PER_USER — per-user purchased-credit cap (100 GiB) CREDIT_EXPIRY_CHECK_INTERVAL — background job interval ms (3 600 000) INTENT_EXPIRY_MINUTES — price-lock window minutes (10) Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/.env.sample | 59 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/backend/.env.sample b/apps/backend/.env.sample index bd2a566c0..6155579a3 100644 --- a/apps/backend/.env.sample +++ b/apps/backend/.env.sample @@ -4,5 +4,60 @@ FILES_GATEWAY_URL=http://changeme.com # change to your own (or it'll fail when f FILES_GATEWAY_TOKEN=changeme # change to your own (or it'll fail when fetching archived files) AUTH_SERVICE_API_KEY=1234567890 # change to your own if updated in auth RABBITMQ_URL=amqp://guest:guest@localhost:5672 -EVM_CHAIN_ENDPOINT=http://localhost:8545 # update it you want to simulate/test buy credits feature -EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 # update it you want to simulate/test buy credits feature \ No newline at end of file + +# --------------------------------------------------------------------------- +# Pay-with-AI3 / purchased credits feature +# --------------------------------------------------------------------------- + +# Feature flags — set BUY_CREDITS_ACTIVE=true to enable the purchase flow. +# BUY_CREDITS_STAFF_ONLY=true restricts it to admin/staff accounts only, +# useful for a staged rollout before opening to all users. +BUY_CREDITS_ACTIVE=false +BUY_CREDITS_STAFF_ONLY=false + +# EVM endpoint for the AutoDriveCreditsReceiver contract. +# Point this at the Auto-EVM RPC for the target network (mainnet or Taurus +# testnet). The payment manager watches this chain for deposit events. +EVM_CHAIN_ENDPOINT=http://localhost:8545 + +# Address of the deployed AutoDriveCreditsReceiver contract. +# Must match the chain pointed to by EVM_CHAIN_ENDPOINT. +EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 + +# Number of block confirmations to wait before treating a payment as final. +# Higher values reduce the risk of processing a payment that is later +# reversed by a chain reorganisation. Default: 6. +EVM_CHAIN_CONFIRMATIONS=6 + +# How often (in milliseconds) the payment manager polls for CONFIRMED intents +# that have not yet had credits applied. This is a fallback for cases where +# the event watcher misses a log. Default: 30000 (30 seconds). +EVM_CHAIN_CHECK_INTERVAL=30000 + +# Price multiplier applied on top of the raw Autonomys consensus fee to +# determine the AI3 cost per byte. A value of 5.0 means users pay 5× the +# current on-chain transaction byte fee. Default: 5.00. +CREDITS_PRICE_MULTIPLIER=5.00 + +# --------------------------------------------------------------------------- +# Credit lifecycle +# --------------------------------------------------------------------------- + +# Number of days after purchase before a credit batch expires. +# Users see this value on the purchase confirmation screen and in their credit +# history. Default: 90. +CREDIT_EXPIRY_DAYS=90 + +# Maximum total purchased upload bytes allowed per user across all active +# (non-expired) credit rows. Attempts to purchase beyond this cap result in +# an OVER_CAP intent requiring admin review. Default: 107374182400 (100 GiB). +MAX_CREDITS_PER_USER=107374182400 + +# How often (in milliseconds) the background job runs to mark expired credit +# rows and clean up stale PENDING intents. Default: 3600000 (1 hour). +CREDIT_EXPIRY_CHECK_INTERVAL=3600000 + +# How many minutes a PENDING intent remains valid before it is treated as +# expired. Users must submit their on-chain transaction within this window +# after creating an intent. Default: 10. +INTENT_EXPIRY_MINUTES=10 From aa36547b1138ee15b705bb5301d26bd33023a9e4 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 14:01:59 +0400 Subject: [PATCH 27/78] feat(admin): add purchased-credits panel to admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces all pay-with-AI3 purchase data in the admin UI so operators can monitor credit health and resolve stuck payments without needing direct DB access. Backend - purchasedCreditsRepository: add getAllWithUserPublicId() — joins purchased_credits with intents to return every batch with its owner's userPublicId, newest-first. - CreditsUseCases: add getAllBatches(executor) — admin-only wrapper returning ForbiddenError for non-admins. - creditsController: add GET /credits/batches/all — serialises bigint fields to strings and includes userPublicId on each row. Frontend (api.ts) - Add AdminCreditBatch, CreditEconomicsResponse, OverCapIntent types. - Add getAdminCreditBatches(), getCreditEconomics(), getOverCapIntents(), reprocessIntent() service methods. Frontend (AdminPanel/AdminCredits.tsx — new component) - Economics card: 3-metric summary (batch count, upload bytes, download bytes) for credits expiring within 30 days. - Over-Cap panel: table of OVER_CAP intents with a Reprocess button per row; invalidates queries on success. - All Batches table: every purchase across all users with user, status badge, purchased date, original/remaining bytes, usage bar, and expiry date. Frontend (AdminPanel/index.tsx) - Render between the analytics section and the users table. Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/src/app/controllers/credits.ts | 38 ++ apps/backend/src/core/users/credits.ts | 26 +- .../repositories/users/purchasedCredits.ts | 25 ++ .../views/AdminPanel/AdminCredits.tsx | 345 ++++++++++++++++++ .../src/components/views/AdminPanel/index.tsx | 6 + apps/frontend/src/services/api.ts | 122 +++++++ 6 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx diff --git a/apps/backend/src/app/controllers/credits.ts b/apps/backend/src/app/controllers/credits.ts index fa3666113..884911870 100644 --- a/apps/backend/src/app/controllers/credits.ts +++ b/apps/backend/src/app/controllers/credits.ts @@ -117,6 +117,44 @@ creditsController.get( }), ) +// --------------------------------------------------------------------------- +// GET /credits/batches/all +// Admin-only: all credit batches across every user, newest-first. +// Each row includes the owner's userPublicId for easy cross-referencing +// with the admin user table. Returns 403 for non-admin callers. +// +// NOTE: registered BEFORE GET /credits/batches so Express does not attempt +// to match the literal string "all" against the existing /batches route +// (they are separate paths and Express won't confuse them, but ordering +// here keeps the admin routes grouped together). +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches/all', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + CreditsUseCases.getAllBatches(user), + 'Failed to get all credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((batch) => ({ + ...serializeCredit(batch), + userPublicId: batch.userPublicId, + })), + ) + }), +) + // --------------------------------------------------------------------------- // 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 f857b0d86..a017637af 100644 --- a/apps/backend/src/core/users/credits.ts +++ b/apps/backend/src/core/users/credits.ts @@ -1,5 +1,8 @@ import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models' -import { purchasedCreditsRepository } from '../../infrastructure/repositories/users/purchasedCredits.js' +import { + AdminCreditBatchRow, + purchasedCreditsRepository, +} from '../../infrastructure/repositories/users/purchasedCredits.js' import { AccountsUseCases } from './accounts.js' import { config } from '../../config.js' import { ForbiddenError } from '../../errors/index.js' @@ -140,9 +143,30 @@ const getEconomics = async ( }) } +// --------------------------------------------------------------------------- +// getAllBatches +// Admin-only: full purchase history across all users with their publicId. +// Returns 403 for non-admin callers. +// --------------------------------------------------------------------------- + +const getAllBatches = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to access all credit batches', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const rows = await purchasedCreditsRepository.getAllWithUserPublicId() + return ok(rows) +} + export const CreditsUseCases = { getSummary, getBatches, getExpiringBatches, getEconomics, + getAllBatches, } diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 825bec175..0361a9064 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -548,6 +548,30 @@ const createPurchasedCreditWithCapCheck = async ( } } +// --------------------------------------------------------------------------- +// getAllWithUserPublicId +// Admin view: every credit batch across all users, joined with the +// user_public_id from the originating intent row. Ordered newest-first. +// --------------------------------------------------------------------------- + +export type AdminCreditBatchRow = PurchasedCredit & { + userPublicId: string +} + +const getAllWithUserPublicId = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT pc.*, i.user_public_id + FROM purchased_credits pc + JOIN intents i ON i.id = pc.intent_id + ORDER BY pc.purchased_at DESC`, + ) + return result.rows.map((row) => ({ + ...mapRow(row), + userPublicId: row.user_public_id, + })) +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -564,4 +588,5 @@ export const purchasedCreditsRepository = { createPurchasedCreditWithCapCheck, markExpiredCredits, getByAccountId, + getAllWithUserPublicId, } diff --git a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx new file mode 100644 index 000000000..20db7fb84 --- /dev/null +++ b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx @@ -0,0 +1,345 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +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 type { + AdminCreditBatch, + CreditEconomicsResponse, + OverCapIntent, +} from '../../../services/api'; + +// --------------------------------------------------------------------------- +// Batch status helpers (mirrors CreditHistory) +// --------------------------------------------------------------------------- + +type BatchStatus = 'active' | 'expiring' | 'depleted' | 'expired'; + +const getBatchStatus = (batch: AdminCreditBatch): BatchStatus => { + if (batch.expired) return 'expired'; + if (BigInt(batch.uploadBytesRemaining) === BigInt(0)) return 'depleted'; + const msLeft = + new Date(batch.expiresAt).getTime() - Date.now(); + const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); + if (daysLeft <= 30) return 'expiring'; + return 'active'; +}; + +const STATUS_CLASSES: Record = { + active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + expiring: + 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + depleted: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + expired: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +const STATUS_LABEL: Record = { + active: 'Active', + expiring: 'Expiring soon', + depleted: 'Depleted', + expired: 'Expired', +}; + +// --------------------------------------------------------------------------- +// Economics summary card +// --------------------------------------------------------------------------- + +const EconomicsCard = ({ + economics, +}: { + economics: CreditEconomicsResponse; +}) => ( +
+
+

Batches expiring ≤ 30 d

+

+ {economics.totalExpiringWithin30Days} +

+
+
+

Upload bytes expiring

+

+ {formatBytes(Number(BigInt(economics.totalExpiringUploadBytes)), 1)} +

+
+
+

Download bytes expiring

+

+ {formatBytes(Number(BigInt(economics.totalExpiringDownloadBytes)), 1)} +

+
+
+); + +// --------------------------------------------------------------------------- +// OVER_CAP intents panel +// --------------------------------------------------------------------------- + +const OverCapPanel = ({ + intents, + onReprocess, + reprocessingId, +}: { + intents: OverCapIntent[]; + onReprocess: (id: string) => void; + reprocessingId: string | null; +}) => { + if (intents.length === 0) { + return ( +

+ No over-cap intents — all payments resolved. +

+ ); + } + + return ( +
+ + + + + + + + + + + + {intents.map((intent) => ( + + + + + + + + ))} + +
Intent IDUserPaid (AI3 shannons)Tx hashAction
+ {intent.id.slice(0, 12)}… + + {intent.userPublicId.slice(0, 12)}… + + {intent.paymentAmount ?? '—'} + + {intent.txHash + ? `${intent.txHash.slice(0, 10)}…` + : '—'} + + +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// All credit batches table +// --------------------------------------------------------------------------- + +const AllBatchesTable = ({ batches }: { batches: AdminCreditBatch[] }) => { + if (batches.length === 0) { + return ( +

+ No credit batches have been purchased yet. +

+ ); + } + + return ( +
+ + + + + + + + + + + + + + {batches.map((batch) => { + const status = getBatchStatus(batch); + const original = Number(BigInt(batch.uploadBytesOriginal)); + const remaining = Number(BigInt(batch.uploadBytesRemaining)); + const usedPct = + original > 0 + ? Math.round(((original - remaining) / original) * 100) + : 0; + + return ( + + + + + + + + + + ); + })} + +
UserStatusPurchasedOriginalRemainingUsed %Expires
+ {batch.userPublicId.slice(0, 14)}… + + + {STATUS_LABEL[status]} + + + {formatDate(batch.purchasedAt)} + + {formatBytes(original, 1)} + + {formatBytes(remaining, 1)} + +
+
+
+
+ + {usedPct}% + +
+
+ {batch.expired ? ( + + Expired {formatDate(batch.expiresAt)} + + ) : ( + formatDate(batch.expiresAt) + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const AdminCredits = () => { + const { api } = useNetwork(); + const queryClient = useQueryClient(); + + const { data: economics, isLoading: economicsLoading } = + useQuery({ + queryKey: ['adminCreditEconomics'], + queryFn: () => api.getCreditEconomics(), + staleTime: 60_000, + }); + + const { data: batches = [], isLoading: batchesLoading } = useQuery< + AdminCreditBatch[] + >({ + queryKey: ['adminCreditBatches'], + queryFn: () => api.getAdminCreditBatches(), + staleTime: 60_000, + }); + + const { data: overCapIntents = [], isLoading: overCapLoading } = useQuery< + OverCapIntent[] + >({ + queryKey: ['adminOverCapIntents'], + queryFn: () => api.getOverCapIntents(), + staleTime: 30_000, + }); + + const { mutate: reprocess, variables: reprocessingId } = useMutation< + void, + Error, + string + >({ + mutationFn: (intentId: string) => api.reprocessIntent(intentId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['adminOverCapIntents'] }); + void queryClient.invalidateQueries({ queryKey: ['adminCreditBatches'] }); + }, + }); + + const isLoading = economicsLoading || batchesLoading || overCapLoading; + + return ( +
+
+

Purchased Credits

+ {isLoading && ( + + )} +
+ + {/* Economics summary */} +
+

+ System Economics (expiring ≤ 30 days) +

+ {economics ? ( + + ) : ( + !economicsLoading && ( +

+ No economics data available. +

+ ) + )} +
+ + {/* OVER_CAP intents */} +
+
+

+ Over-Cap Intents +

+ {overCapIntents.length > 0 && ( + + + {overCapIntents.length} need review + + )} +
+

+ Payments confirmed on-chain that could not be converted to credits + because the user hit the per-user cap. Raise the cap via the account + editor, then click Reprocess to retry. +

+ reprocess(id)} + reprocessingId={reprocessingId ?? null} + /> +
+ + {/* All batches table */} +
+

+ All Purchase Batches ({batches.length}) +

+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/views/AdminPanel/index.tsx b/apps/frontend/src/components/views/AdminPanel/index.tsx index 2ad52befe..bc4099bf5 100644 --- a/apps/frontend/src/components/views/AdminPanel/index.tsx +++ b/apps/frontend/src/components/views/AdminPanel/index.tsx @@ -11,6 +11,7 @@ import { import { useNetwork } from 'contexts/network'; import { Button } from '@auto-drive/ui'; import { AdminStats } from './AdminStats'; +import { AdminCredits } from './AdminCredits'; export const AdminPanel = () => { const [accountsWithUsers, setAccountsWithUsers] = useState< @@ -134,6 +135,11 @@ export const AdminPanel = () => { {/* Analytics Section */} + {/* Purchased Credits Section */} +
+ +
+ {/* Users Section */}

Users

diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 91a4e0892..e82057965 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -35,6 +35,30 @@ export type ExpiringCreditBatch = { createdAt: string; updatedAt: string; }; + +// Wire-format of rows from GET /credits/batches/all (admin endpoint). +// Extends ExpiringCreditBatch with the owner's userPublicId. +export type AdminCreditBatch = ExpiringCreditBatch & { + userPublicId: string; +}; + +// Wire-format of GET /credits/economics (admin) +export type CreditEconomicsResponse = { + totalExpiringWithin30Days: number; + totalExpiringUploadBytes: string; + totalExpiringDownloadBytes: string; +}; + +// Wire-format of rows from GET /intents/over-cap (admin) +export type OverCapIntent = { + id: string; + userPublicId: string; + status: string; + txHash?: string; + paymentAmount?: string; + shannonsPerByte: string; + expiresAt?: string; +}; import { getAuthSession } from 'utils/auth'; import { uploadFileContent } from 'utils/file'; @@ -553,4 +577,102 @@ export const createApiService = ({ return response.json(); }, + + // ------------------------------------------------------------------------- + // Admin: all credit batches across all users + // ------------------------------------------------------------------------- + + getAdminCreditBatches: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/credits/batches/all`, { + 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: system-wide credit economics summary + // ------------------------------------------------------------------------- + + getCreditEconomics: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/credits/economics`, { + 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: list OVER_CAP intents + // ------------------------------------------------------------------------- + + getOverCapIntents: async (): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch(`${apiBaseUrl}/intents/over-cap`, { + 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: reprocess a single OVER_CAP intent + // ------------------------------------------------------------------------- + + reprocessIntent: async (intentId: string): Promise => { + const session = await getAuthSession(); + if (!session?.authProvider || !session.accessToken) { + throw new Error('No session'); + } + + const response = await fetch( + `${apiBaseUrl}/intents/${intentId}/reprocess`, + { + 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}`); + } + }, }); From 3d8a41dce45890cafde3956dbd9040f7fcd89341 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 15:29:58 +0400 Subject: [PATCH 28/78] fix(credits): suppress buy-more CTA flash during loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The showBuyMoreCta memo treated an empty batches array as "no active batches" and showed the CTA, but batches defaults to [] while the query is still loading — causing the banner to flash on every page load alongside the spinner. Guard on isLoading to prevent this. Made-with: Cursor --- apps/frontend/src/components/views/CreditHistory/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/views/CreditHistory/index.tsx b/apps/frontend/src/components/views/CreditHistory/index.tsx index ea65f2934..2d71c2c74 100644 --- a/apps/frontend/src/components/views/CreditHistory/index.tsx +++ b/apps/frontend/src/components/views/CreditHistory/index.tsx @@ -169,7 +169,7 @@ export const CreditHistoryView = () => { // Show CTA when user has no active non-depleted batches or all are expiring // within 7 days. const showBuyMoreCta = useMemo(() => { - if (!hasBuyCreditsFeature) return false; + if (!hasBuyCreditsFeature || isLoading) return false; if (batches.length === 0) return true; const activeBatches = batches.filter((b: ExpiringCreditBatch) => { if (b.expired) return false; @@ -178,7 +178,7 @@ export const CreditHistoryView = () => { return days === null || days > 7; }); return activeBatches.length === 0; - }, [batches, hasBuyCreditsFeature]); + }, [batches, hasBuyCreditsFeature, isLoading]); if (!hasBuyCreditsFeature) { return ( From 7224c15e547e6a0a3f134746b43de6fcbda28d5a Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 15:32:34 +0400 Subject: [PATCH 29/78] fix(intents): transition dust-payment intents to FAILED instead of retrying The dust payment guard returned err() without moving the intent to a terminal state, leaving it CONFIRMED. Since _checkConfirmedIntents polls every 30s and paymentAmount/shannonsPerByte are immutable, this caused an infinite retry loop. Now marks the intent FAILED (terminal), matching the OVER_CAP pattern. Made-with: Cursor --- apps/backend/src/core/users/intents.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index 07930d65c..d5f0d9ed6 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -216,22 +216,25 @@ const onConfirmedIntent = async (intentId: string) => { // intent COMPLETED while giving the user nothing — a misleading outcome that // wastes a DB row and silently discards the payment. // - // Instead we log a warning and return an error so the polling loop retries - // (in case of a transient pricing mismatch) and operators are alerted. - // In practice this should never happen because the frontend enforces a - // minimum package size, but the guard defends against buggy clients or - // future contract changes. + // Both paymentAmount and shannonsPerByte are immutable on a confirmed intent, + // so this condition is permanent. We mark the intent FAILED (terminal) so + // the polling loop stops retrying. The on-chain payment is irreversible; + // resolution requires admin review (similar to OVER_CAP handling). const creditBytes = IntentsUseCases.getIntentCredits(intent) if (creditBytes === BigInt(0)) { logger.warn( - 'onConfirmedIntent: payment too small to yield any credits — skipping', + 'onConfirmedIntent: payment too small to yield any credits — marking FAILED', { intentId, paymentAmount: intent.paymentAmount.toString(), shannonsPerByte: intent.shannonsPerByte.toString(), }, ) - return err(new Error('Payment amount yields zero credits')) + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.FAILED, + }) + return ok() } const addResult = await AccountsUseCases.addCreditsToAccount( From fe741f8772408586eaae139ac1e05c50d76c6920 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 15:33:55 +0400 Subject: [PATCH 30/78] fix(admin): gate Reprocess button on isPending to avoid stuck disabled state The button's disabled/label state relied solely on useMutation's `variables`, which persists after the mutation settles (including on error). Now checks `isPending && reprocessingId` so the button re-enables as soon as the mutation completes. Made-with: Cursor --- .../src/components/views/AdminPanel/AdminCredits.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx index 20db7fb84..4e210a88b 100644 --- a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx +++ b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx @@ -82,10 +82,12 @@ const OverCapPanel = ({ intents, onReprocess, reprocessingId, + isPending, }: { intents: OverCapIntent[]; onReprocess: (id: string) => void; reprocessingId: string | null; + isPending: boolean; }) => { if (intents.length === 0) { return ( @@ -130,12 +132,12 @@ const OverCapPanel = ({ @@ -269,7 +271,7 @@ export const AdminCredits = () => { staleTime: 30_000, }); - const { mutate: reprocess, variables: reprocessingId } = useMutation< + const { mutate: reprocess, variables: reprocessingId, isPending: isReprocessing } = useMutation< void, Error, string @@ -330,6 +332,7 @@ export const AdminCredits = () => { intents={overCapIntents} onReprocess={(id) => reprocess(id)} reprocessingId={reprocessingId ?? null} + isPending={isReprocessing} />
From d567e62d6e8da2df99e799801d41696ba376db73 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 15:38:09 +0400 Subject: [PATCH 31/78] refactor(credits): extract batch status helpers into shared utils/credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBatchStatus, STATUS_CLASSES, STATUS_LABEL, and BatchStatus were duplicated between CreditHistory and AdminCredits. The AdminCredits copy also reimplemented the days-until-expiry calculation inline (missing the Math.max(0, …) clamp). Consolidate into utils/credits.ts so both views share a single source of truth and add unit tests for getBatchStatus. Made-with: Cursor --- .../__tests__/unit/utils/credits.spec.ts | 53 ++++++++++++++++++- .../views/AdminPanel/AdminCredits.tsx | 32 +---------- .../components/views/CreditHistory/index.tsx | 36 +++---------- apps/frontend/src/utils/credits.ts | 36 +++++++++++++ 4 files changed, 95 insertions(+), 62 deletions(-) diff --git a/apps/frontend/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts index 8d9977490..0e689e0bd 100644 --- a/apps/frontend/__tests__/unit/utils/credits.spec.ts +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -1,4 +1,4 @@ -import { isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes } from '../../../src/utils/credits' +import { isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes, getBatchStatus } from '../../../src/utils/credits' // --------------------------------------------------------------------------- // isPackageOverCap @@ -128,3 +128,54 @@ describe('sumExpiringUploadBytes', () => { expect(sumExpiringUploadBytes(batches)).toBe(oneMiB * 3n) }) }) + +// --------------------------------------------------------------------------- +// getBatchStatus +// --------------------------------------------------------------------------- + +describe('getBatchStatus', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-06-01T00:00:00Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const makeBatch = (overrides: Partial<{ expired: boolean; uploadBytesRemaining: string; expiresAt: string }> = {}) => ({ + expired: false, + uploadBytesRemaining: '1048576', + expiresAt: '2026-12-01T00:00:00Z', + ...overrides, + }) + + it('returns "expired" when batch.expired is true', () => { + expect(getBatchStatus(makeBatch({ expired: true }))).toBe('expired') + }) + + it('returns "depleted" when uploadBytesRemaining is zero', () => { + expect(getBatchStatus(makeBatch({ uploadBytesRemaining: '0' }))).toBe('depleted') + }) + + it('returns "expiring" when the batch expires within 30 days', () => { + // 15 days from "now" (2026-06-01) + expect(getBatchStatus(makeBatch({ expiresAt: '2026-06-16T00:00:00Z' }))).toBe('expiring') + }) + + it('returns "expiring" when the batch expires in exactly 30 days', () => { + expect(getBatchStatus(makeBatch({ expiresAt: '2026-07-01T00:00:00Z' }))).toBe('expiring') + }) + + it('returns "active" when the batch expires in more than 30 days', () => { + expect(getBatchStatus(makeBatch({ expiresAt: '2026-07-02T00:00:00Z' }))).toBe('active') + }) + + it('prioritises "expired" over "depleted"', () => { + expect(getBatchStatus(makeBatch({ expired: true, uploadBytesRemaining: '0' }))).toBe('expired') + }) + + it('prioritises "depleted" over "expiring"', () => { + expect(getBatchStatus(makeBatch({ uploadBytesRemaining: '0', expiresAt: '2026-06-10T00:00:00Z' }))).toBe('depleted') + }) +}) diff --git a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx index 4e210a88b..4b2937d9b 100644 --- a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx +++ b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx @@ -6,43 +6,13 @@ import { formatBytes } from '../../../utils/number'; import { formatDate } from '../../../utils/time'; import { RefreshCw, AlertTriangle, RotateCcw } from 'lucide-react'; import { Button } from '@auto-drive/ui'; +import { getBatchStatus, STATUS_CLASSES, STATUS_LABEL } from '../../../utils/credits'; import type { AdminCreditBatch, CreditEconomicsResponse, OverCapIntent, } from '../../../services/api'; -// --------------------------------------------------------------------------- -// Batch status helpers (mirrors CreditHistory) -// --------------------------------------------------------------------------- - -type BatchStatus = 'active' | 'expiring' | 'depleted' | 'expired'; - -const getBatchStatus = (batch: AdminCreditBatch): BatchStatus => { - if (batch.expired) return 'expired'; - if (BigInt(batch.uploadBytesRemaining) === BigInt(0)) return 'depleted'; - const msLeft = - new Date(batch.expiresAt).getTime() - Date.now(); - const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); - if (daysLeft <= 30) return 'expiring'; - return 'active'; -}; - -const STATUS_CLASSES: Record = { - active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', - expiring: - 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', - depleted: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', - expired: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', -}; - -const STATUS_LABEL: Record = { - active: 'Active', - expiring: 'Expiring soon', - depleted: 'Depleted', - expired: 'Expired', -}; - // --------------------------------------------------------------------------- // Economics summary card // --------------------------------------------------------------------------- diff --git a/apps/frontend/src/components/views/CreditHistory/index.tsx b/apps/frontend/src/components/views/CreditHistory/index.tsx index ea65f2934..3a5587ae1 100644 --- a/apps/frontend/src/components/views/CreditHistory/index.tsx +++ b/apps/frontend/src/components/views/CreditHistory/index.tsx @@ -6,42 +6,18 @@ import { useUserStore } from '../../../globalStates/user'; import { AccountModel } from '@auto-drive/models'; import { formatBytes } from '../../../utils/number'; import { formatDate } from '../../../utils/time'; -import { daysUntilExpiry } from '../../../utils/credits'; +import { + daysUntilExpiry, + getBatchStatus, + STATUS_CLASSES, + STATUS_LABEL, +} from '../../../utils/credits'; import Link from 'next/link'; import { Button } from '@auto-drive/ui'; import { ShoppingCart, RefreshCw } from 'lucide-react'; import { useMemo } from 'react'; import type { ExpiringCreditBatch } from '../../../services/api'; -// --------------------------------------------------------------------------- -// Batch status helpers -// --------------------------------------------------------------------------- - -type BatchStatus = 'active' | 'expiring' | 'depleted' | 'expired'; - -const getBatchStatus = (batch: ExpiringCreditBatch): BatchStatus => { - if (batch.expired) return 'expired'; - if (BigInt(batch.uploadBytesRemaining) === BigInt(0)) return 'depleted'; - const days = daysUntilExpiry(new Date(batch.expiresAt)); - if (days !== null && days <= 30) return 'expiring'; - return 'active'; -}; - -const STATUS_LABEL: Record = { - active: 'Active', - expiring: 'Expiring soon', - depleted: 'Depleted', - expired: 'Expired', -}; - -const STATUS_CLASSES: Record = { - active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', - expiring: - 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', - depleted: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', - expired: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', -}; - // --------------------------------------------------------------------------- // Consumption progress bar // --------------------------------------------------------------------------- diff --git a/apps/frontend/src/utils/credits.ts b/apps/frontend/src/utils/credits.ts index a2616dcf5..14320c5c0 100644 --- a/apps/frontend/src/utils/credits.ts +++ b/apps/frontend/src/utils/credits.ts @@ -38,3 +38,39 @@ export const sumExpiringUploadBytes = ( batches: { uploadBytesRemaining: string }[], ): bigint => batches.reduce((acc, b) => acc + BigInt(b.uploadBytesRemaining), BigInt(0)); + +// --------------------------------------------------------------------------- +// Batch status classification — shared by CreditHistory and AdminCredits +// --------------------------------------------------------------------------- + +export type BatchStatus = 'active' | 'expiring' | 'depleted' | 'expired'; + +export interface BatchStatusFields { + expired: boolean; + uploadBytesRemaining: string; + expiresAt: string; +} + +export const getBatchStatus = (batch: BatchStatusFields): BatchStatus => { + if (batch.expired) return 'expired'; + if (BigInt(batch.uploadBytesRemaining) === BigInt(0)) return 'depleted'; + const days = daysUntilExpiry(new Date(batch.expiresAt)); + if (days !== null && days <= 30) return 'expiring'; + return 'active'; +}; + +export const STATUS_CLASSES: Record = { + active: + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + expiring: + 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + depleted: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', + expired: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +export const STATUS_LABEL: Record = { + active: 'Active', + expiring: 'Expiring soon', + depleted: 'Depleted', + expired: 'Expired', +}; From edb0a36a2a98fad52d506978c5d7a9d2ae699615 Mon Sep 17 00:00:00 2001 From: Emil F Date: Fri, 20 Mar 2026 15:57:47 +0400 Subject: [PATCH 32/78] fix(intents): include FAILED and EXPIRED in markIntentAsConfirmed idempotency guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard only checked CONFIRMED, COMPLETED, and OVER_CAP, allowing a chain reorg to overwrite a FAILED (dust-payment) or EXPIRED intent back to CONFIRMED — creating a perpetual confirm→fail cycle with unnecessary DB writes. Made-with: Cursor --- apps/backend/src/core/users/intents.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index d5f0d9ed6..0e187dc2d 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -166,7 +166,9 @@ const markIntentAsConfirmed = async ({ if ( intent.status === IntentStatus.CONFIRMED || intent.status === IntentStatus.COMPLETED || - intent.status === IntentStatus.OVER_CAP + intent.status === IntentStatus.OVER_CAP || + intent.status === IntentStatus.FAILED || + intent.status === IntentStatus.EXPIRED ) { logger.info('markIntentAsConfirmed: intent already processed — skipping', { intentId, From aef581e3fdf34fa1092cae4f1b47256a9f94e7e9 Mon Sep 17 00:00:00 2001 From: todd-subspace <92994156+todd-subspace@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:55:50 -0400 Subject: [PATCH 33/78] Add GitHub Actions workflow for Claude code review --- .github/workflows/claude-review.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..0d8b90418 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,28 @@ +name: Claude Code Review + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + claude-review: + runs-on: ubuntu-latest + if: | + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From b84c080e4662551b224607907dc0b69b48955655 Mon Sep 17 00:00:00 2001 From: todd-subspace <92994156+todd-subspace@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:47:07 -0400 Subject: [PATCH 34/78] Pin action versions to commit hashes for supply chain security --- .github/workflows/claude-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 0d8b90418..dc8075ed1 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -18,10 +18,10 @@ jobs: issues: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | From f6f6d38673b0547824a57079319ccc93b0164e46 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:09:30 +0000 Subject: [PATCH 35/78] fix: pin flatted >=3.4.0 to address GHSA-25h7-pfq9-p65f Adds yarn resolutions override for flatted (transitive dev dep via eslint -> file-entry-cache -> flat-cache). Not exploitable in production but silences the alert. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 1a4f5caca..1027819d9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-neverthrow": "^1.1.4" }, + "resolutions": { + "flatted": "^3.4.0" + }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", "cache-manager": "^6.4.1", From 071304d5726148926dde8f3ff68f96bd24798dc6 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:09:38 +0000 Subject: [PATCH 36/78] fix: pin fast-xml-parser >=5.5.6 to address GHSA-8gc5-j5rx-235r Transitive prod dep via AWS SDK. Vulnerability requires MITM of AWS API responses to exploit, so real-world risk is low. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1027819d9..150b69599 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "eslint-plugin-neverthrow": "^1.1.4" }, "resolutions": { - "flatted": "^3.4.0" + "flatted": "^3.4.0", + "fast-xml-parser": "^5.5.6" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", From 46fcbec7f256f77b507cb1f70555ef6ca3d9267b Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:09:48 +0000 Subject: [PATCH 37/78] fix: pin minimatch per major version to address GHSA-7r86-cg39-jmmj Three version lines in use (3.x, 5.x, 9.x) all via dev tooling. Uses Yarn descriptor syntax to target each major independently. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 150b69599..8ac0b0e9c 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ }, "resolutions": { "flatted": "^3.4.0", - "fast-xml-parser": "^5.5.6" + "fast-xml-parser": "^5.5.6", + "minimatch@^3": "^3.1.3", + "minimatch@^5": "^5.1.8", + "minimatch@^9": "^9.0.7" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", From c39741c7f263bbab66495cc0b6a74a728b82788b Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:09:57 +0000 Subject: [PATCH 38/78] fix: pin undici >=6.24.0 to address GHSA-vrm6-8vpv-qv8q MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major bump from 5.x required as no 5.x patch exists. Transitive dev dep via @types/node and testcontainers only — no prod impact. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ac0b0e9c..b22613013 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "fast-xml-parser": "^5.5.6", "minimatch@^3": "^3.1.3", "minimatch@^5": "^5.1.8", - "minimatch@^9": "^9.0.7" + "minimatch@^9": "^9.0.7", + "undici": "^6.24.0" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", From 758e3b09b0361c67d73bc229dc09b0113429a454 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:10:09 +0000 Subject: [PATCH 39/78] fix: pin h3 >=1.15.6 to address GHSA-22cc-p3c6-wpvm SSE injection via createEventStream(). Transitive frontend dep via @walletconnect/keyvaluestorage -> unstorage. The SSE code path is not exercised by WalletConnect's storage usage. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b22613013..4288521ae 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "minimatch@^3": "^3.1.3", "minimatch@^5": "^5.1.8", "minimatch@^9": "^9.0.7", - "undici": "^6.24.0" + "undici": "^6.24.0", + "h3": "^1.15.6" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", From dfdac12f5007d2d889c8ddb8a8e04bd235e2e082 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:10:17 +0000 Subject: [PATCH 40/78] fix: pin socket.io-parser >=4.2.6 to address GHSA-677m-j7p3-52f9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoS via binary attachment buffering. Transitive frontend dep via @metamask/sdk -> socket.io-client. Client-side usage inverts the threat model — requires a malicious MetaMask relay to exploit. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4288521ae..c31610a71 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "minimatch@^5": "^5.1.8", "minimatch@^9": "^9.0.7", "undici": "^6.24.0", - "h3": "^1.15.6" + "h3": "^1.15.6", + "socket.io-parser": "^4.2.6" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", From a58ac6a2e61892d812a103f10bff57a9c91cdfd8 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:20:43 +0000 Subject: [PATCH 41/78] fix: correct minimatch resolution descriptors to use npm: protocol The short-form @^3 / @^5 / @^9 syntax was silently ignored by Yarn 4. Switched to explicit npm: protocol descriptors matching the actual ranges present in the lockfile. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index c31610a71..ac42ce547 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,15 @@ "resolutions": { "flatted": "^3.4.0", "fast-xml-parser": "^5.5.6", - "minimatch@^3": "^3.1.3", - "minimatch@^5": "^5.1.8", - "minimatch@^9": "^9.0.7", + "minimatch@npm:^3.0.4": "^3.1.3", + "minimatch@npm:^3.0.5": "^3.1.3", + "minimatch@npm:^3.1.1": "^3.1.3", + "minimatch@npm:^3.1.2": "^3.1.3", + "minimatch@npm:^5.0.1": "^5.1.8", + "minimatch@npm:^5.1.0": "^5.1.8", + "minimatch@npm:^9.0.1": "^9.0.7", + "minimatch@npm:^9.0.4": "^9.0.7", + "minimatch@npm:^9.0.5": "^9.0.7", "undici": "^6.24.0", "h3": "^1.15.6", "socket.io-parser": "^4.2.6" From 9385651014da1b0ac3783cde776621c46f39c005 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:20:49 +0000 Subject: [PATCH 42/78] chore: update yarn.lock with patched dependency resolutions Reflects resolved versions after applying all 6 vulnerability resolutions: flatted 3.4.2, fast-xml-parser 5.5.8, minimatch 3.1.5/5.1.9/9.0.9, undici 6.24.1, h3 1.15.10, socket.io-parser 4.2.6. Co-Authored-By: Claude Sonnet 4.6 --- yarn.lock | 128 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/yarn.lock b/yarn.lock index 64ff3663d..663f8f5d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2410,13 +2410,6 @@ __metadata: languageName: node linkType: hard -"@fastify/busboy@npm:^2.0.0": - version: 2.1.1 - resolution: "@fastify/busboy@npm:2.1.1" - checksum: 10c0/6f8027a8cba7f8f7b736718b013f5a38c0476eea67034c94a0d3c375e2b114366ad4419e6a6fa7ffc2ef9c6d3e0435d76dd584a7a1cbac23962fda7650b579e3 - languageName: node - linkType: hard - "@fastify/busboy@npm:^3.1.1": version: 3.2.0 resolution: "@fastify/busboy@npm:3.2.0" @@ -10421,7 +10414,7 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^2.0.1": +"brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": version: 2.0.2 resolution: "brace-expansion@npm:2.0.2" dependencies: @@ -11658,7 +11651,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0": +"debug@npm:4, debug@npm:^4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:~4.4.1": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -13410,14 +13403,25 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:5.2.5": - version: 5.2.5 - resolution: "fast-xml-parser@npm:5.2.5" +"fast-xml-builder@npm:^1.1.4": + version: 1.1.4 + resolution: "fast-xml-builder@npm:1.1.4" dependencies: - strnum: "npm:^2.1.0" + path-expression-matcher: "npm:^1.1.3" + checksum: 10c0/d5dfc0660f7f886b9f42747e6aa1d5e16c090c804b322652f65a5d7ffb93aa00153c3e1276cd053629f9f4c4f625131dc6886677394f7048e827e63b97b18927 + languageName: node + linkType: hard + +"fast-xml-parser@npm:^5.5.6": + version: 5.5.8 + resolution: "fast-xml-parser@npm:5.5.8" + dependencies: + fast-xml-builder: "npm:^1.1.4" + path-expression-matcher: "npm:^1.2.0" + strnum: "npm:^2.2.0" bin: fxparser: src/cli/cli.js - checksum: 10c0/d1057d2e790c327ccfc42b872b91786a4912a152d44f9507bf053f800102dfb07ece3da0a86b33ff6a0caa5a5cad86da3326744f6ae5efb0c6c571d754fe48cd + checksum: 10c0/b0eb5b5b4b02bb2dfac2fac4c19ce834017553e1f74499929a196b67bfe0741389a89dca4662c97bff138646d7c5fd985af59c7a216c433717e854de3355638c languageName: node linkType: hard @@ -13668,10 +13672,10 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.2.9": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 +"flatted@npm:^3.4.0": + version: 3.4.2 + resolution: "flatted@npm:3.4.2" + checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed languageName: node linkType: hard @@ -14276,20 +14280,20 @@ __metadata: languageName: node linkType: hard -"h3@npm:^1.15.4": - version: 1.15.4 - resolution: "h3@npm:1.15.4" +"h3@npm:^1.15.6": + version: 1.15.10 + resolution: "h3@npm:1.15.10" dependencies: cookie-es: "npm:^1.2.2" crossws: "npm:^0.3.5" defu: "npm:^6.1.4" destr: "npm:^2.0.5" iron-webcrypto: "npm:^1.2.1" - node-mock-http: "npm:^1.0.2" + node-mock-http: "npm:^1.0.4" radix3: "npm:^1.1.2" - ufo: "npm:^1.6.1" + ufo: "npm:^1.6.3" uncrypto: "npm:^0.1.3" - checksum: 10c0/5182a722d01fe18af5cb62441aaa872b630f4e1ac2cf1782e1f442e65fdfddb85eb6723bf73a96184c2dc1f1e3771d713ef47c456a9a4e92c640b025ba91044c + checksum: 10c0/5eec10ea46905e36fdc645b359367f159a316e3e5cc00d0fd13e63db9c5b9793e6c0b65b77daa27339a95ad5837b35920533a4e4556349d256faa04a749ba1b0 languageName: node linkType: hard @@ -16980,30 +16984,30 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" +"minimatch@npm:^3.1.3": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" dependencies: brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 languageName: node linkType: hard -"minimatch@npm:^5.0.1, minimatch@npm:^5.1.0": - version: 5.1.6 - resolution: "minimatch@npm:5.1.6" +"minimatch@npm:^5.1.8": + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" dependencies: brace-expansion: "npm:^2.0.1" - checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 + checksum: 10c0/4202718683815a7288b13e470160a4f9560cf392adef4f453927505817e01ef6b3476ecde13cfcaed17e7326dd3b69ad44eb2daeb19a217c5500f9277893f1d6 languageName: node linkType: hard -"minimatch@npm:^9.0.1, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" +"minimatch@npm:^9.0.7": + version: 9.0.9 + resolution: "minimatch@npm:9.0.9" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + brace-expansion: "npm:^2.0.2" + checksum: 10c0/0b6a58530dbb00361745aa6c8cffaba4c90f551afe7c734830bd95fd88ebf469dd7355a027824ea1d09e37181cfeb0a797fb17df60c15ac174303ac110eb7e86 languageName: node linkType: hard @@ -17692,10 +17696,10 @@ __metadata: languageName: node linkType: hard -"node-mock-http@npm:^1.0.2": - version: 1.0.3 - resolution: "node-mock-http@npm:1.0.3" - checksum: 10c0/663f2a13518fc89b0dc69f96ba4442b5d1ecbbf20a833283725c8d2d92286af1b634803822432985be5999317fd5f23edbf2a62335fe6dd38d6b19dd7b107559 +"node-mock-http@npm:^1.0.4": + version: 1.0.4 + resolution: "node-mock-http@npm:1.0.4" + checksum: 10c0/86e3f7453cf07ad6b8bd17cf89ff91d45f486a861cf6d891618cf29647d559cbcde1d1f90c9cc02e014ff9f7900b2fb21c96b03ea4b4a415dbe2d65badadceba languageName: node linkType: hard @@ -18396,6 +18400,13 @@ __metadata: languageName: node linkType: hard +"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.2.0": + version: 1.2.0 + resolution: "path-expression-matcher@npm:1.2.0" + checksum: 10c0/86c661dfb265ed5dd1ddd9188f0dfbecf4ec4dc3ea6cabab081d3a2ba285054d9767a641a233bd6fd694fd89f7d0ef94913032feddf5365252700b02db4bf4e1 + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -20624,13 +20635,13 @@ __metadata: languageName: node linkType: hard -"socket.io-parser@npm:~4.2.4": - version: 4.2.4 - resolution: "socket.io-parser@npm:4.2.4" +"socket.io-parser@npm:^4.2.6": + version: 4.2.6 + resolution: "socket.io-parser@npm:4.2.6" dependencies: "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.3.1" - checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 + debug: "npm:~4.4.1" + checksum: 10c0/ba0a0b541b0a8e9d02b45c04c4c93a02331be5ea3478073c65bb9ff87032f12469c9adb309728eb90c0a352618d645ab88999c167a11c783cac861d7fd35c9d1 languageName: node linkType: hard @@ -21096,10 +21107,10 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.1.0": - version: 2.1.1 - resolution: "strnum@npm:2.1.1" - checksum: 10c0/1f9bd1f9b4c68333f25c2b1f498ea529189f060cd50aa59f1876139c994d817056de3ce57c12c970f80568d75df2289725e218bd9e3cdf73cd1a876c9c102733 +"strnum@npm:^2.2.0": + version: 2.2.2 + resolution: "strnum@npm:2.2.2" + checksum: 10c0/89c456de32b9495ae34cd6e3b59cb9ef3406b66d1429bbc931afd70be87485dcd355200c42fd638a132adb3121762542346813098ab0c43e44aac303bf17965d languageName: node linkType: hard @@ -22029,6 +22040,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.6.3": + version: 1.6.3 + resolution: "ufo@npm:1.6.3" + checksum: 10c0/bf0e4ebff99e54da1b9c7182ac2f40475988b41faa881d579bc97bc2a0509672107b0a0e94c4b8d31a0ab8c4bf07f4aa0b469ac6da8536d56bda5b085ea2e953 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4, uglify-js@npm:^3.7.7": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" @@ -22152,12 +22170,10 @@ __metadata: languageName: node linkType: hard -"undici@npm:^5.29.0": - version: 5.29.0 - resolution: "undici@npm:5.29.0" - dependencies: - "@fastify/busboy": "npm:^2.0.0" - checksum: 10c0/e4e4d631ca54ee0ad82d2e90e7798fa00a106e27e6c880687e445cc2f13b4bc87c5eba2a88c266c3eecffb18f26e227b778412da74a23acc374fca7caccec49b +"undici@npm:^6.24.0": + version: 6.24.1 + resolution: "undici@npm:6.24.1" + checksum: 10c0/53fdbaa357139a2c12deed34f67d67fc6ad269630ba85a1507e7717f53ad2d3a02c95fbd17d3ab321e34c60b6f0a716cdc2f7e2eca1e07178702dc89cc3a73c4 languageName: node linkType: hard From 63fdd0858803908d724301d5f9b4e92f006f4f4f Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Mon, 23 Mar 2026 09:39:39 +0000 Subject: [PATCH 43/78] fix: tighten socket.io-parser resolution to tilde range (~4.2.6) Caret range (^4.2.6) could drift to 4.3.x on future lockfile refresh, exceeding what socket.io-client@4.8.1 was tested with (~4.2.4). Tilde matches the upstream constraint intent (patch-only updates). Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ac42ce547..269c3103e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "minimatch@npm:^9.0.5": "^9.0.7", "undici": "^6.24.0", "h3": "^1.15.6", - "socket.io-parser": "^4.2.6" + "socket.io-parser": "~4.2.6" }, "dependencies": { "@icons-pack/react-simple-icons": "^13.7.0", diff --git a/yarn.lock b/yarn.lock index 663f8f5d5..7f3c3afdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20635,7 +20635,7 @@ __metadata: languageName: node linkType: hard -"socket.io-parser@npm:^4.2.6": +"socket.io-parser@npm:~4.2.6": version: 4.2.6 resolution: "socket.io-parser@npm:4.2.6" dependencies: From fdbeb255ee503caf126043e5738bdbb5c508824b Mon Sep 17 00:00:00 2001 From: Emil F Date: Tue, 24 Mar 2026 07:33:54 +0400 Subject: [PATCH 44/78] fix(frontend): treat expired intent as a terminal polling state `expired` intents will never transition to `completed`, so continuing to poll indefinitely left the UI stuck in a loading state forever. Handle `expired` the same way as `over_cap`: stop polling immediately, set `isExpired: true`, and return that flag to callers so the UI can surface an actionable error message. Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/useTransactionConfirmation.spec.ts | 33 ++++++++++++------- .../src/hooks/useTransactionConfirmation.ts | 15 +++++++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts index b7dfaf7cf..ce6718f4e 100644 --- a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts +++ b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts @@ -16,6 +16,8 @@ interface PollResult { completed: boolean /** True if the intent hit the per-user cap and credits were NOT applied. */ overCap: boolean + /** True if the intent has expired and credits will never be applied. */ + expired: boolean /** True if polling should continue on the next iteration. */ shouldContinue: boolean } @@ -26,13 +28,16 @@ interface PollResult { */ function evaluateIntentStatus(status: IntentStatus): PollResult { if (status === 'completed') { - return { completed: true, overCap: false, shouldContinue: false } + return { completed: true, overCap: false, expired: false, shouldContinue: false } } if (status === 'over_cap') { - return { completed: false, overCap: true, shouldContinue: false } + return { completed: false, overCap: true, expired: false, shouldContinue: false } } - // Any other status → keep polling - return { completed: false, overCap: false, shouldContinue: true } + if (status === 'expired') { + return { completed: false, overCap: false, expired: true, shouldContinue: false } + } + // Any other status (pending, confirmed, failed) → keep polling + return { completed: false, overCap: false, expired: false, shouldContinue: true } } // --------------------------------------------------------------------------- @@ -44,6 +49,7 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = const result = evaluateIntentStatus('completed') expect(result.completed).toBe(true) expect(result.overCap).toBe(false) + expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(false) }) @@ -51,6 +57,15 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = const result = evaluateIntentStatus('over_cap') expect(result.completed).toBe(false) expect(result.overCap).toBe(true) + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(false) + }) + + it('marks expired=true and stops polling when status is "expired"', () => { + const result = evaluateIntentStatus('expired') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.expired).toBe(true) expect(result.shouldContinue).toBe(false) }) @@ -58,6 +73,7 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = const result = evaluateIntentStatus('pending') expect(result.completed).toBe(false) expect(result.overCap).toBe(false) + expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) @@ -65,6 +81,7 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = const result = evaluateIntentStatus('confirmed') expect(result.completed).toBe(false) expect(result.overCap).toBe(false) + expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) @@ -72,13 +89,7 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = const result = evaluateIntentStatus('failed') expect(result.completed).toBe(false) expect(result.overCap).toBe(false) - expect(result.shouldContinue).toBe(true) - }) - - it('continues polling when status is "expired"', () => { - const result = evaluateIntentStatus('expired') - expect(result.completed).toBe(false) - expect(result.overCap).toBe(false) + expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) diff --git a/apps/frontend/src/hooks/useTransactionConfirmation.ts b/apps/frontend/src/hooks/useTransactionConfirmation.ts index c6533e683..3c43d0d04 100644 --- a/apps/frontend/src/hooks/useTransactionConfirmation.ts +++ b/apps/frontend/src/hooks/useTransactionConfirmation.ts @@ -21,6 +21,8 @@ interface UseTransactionConfirmationReturn { isBackendCompleted: boolean; /** True when the backend put the intent in the over_cap terminal state. */ isOverCap: boolean; + /** True when the intent has expired and credits will never be applied. */ + isExpired: boolean; waitError: Error | null; } @@ -48,6 +50,7 @@ export const useTransactionConfirmation = ({ const [isPollingBackend, setIsPollingBackend] = useState(false); const [isBackendCompleted, setIsBackendCompleted] = useState(false); const [isOverCap, setIsOverCap] = useState(false); + const [isExpired, setIsExpired] = useState(false); // Start watching block numbers to compute confirmations once included useEffect(() => { @@ -90,7 +93,7 @@ export const useTransactionConfirmation = ({ // After confirmations threshold, poll backend until IntentStatus.COMPLETED useEffect(() => { - if (!api || !intentId || !isFullyConfirmed || isBackendCompleted || isOverCap) return; + if (!api || !intentId || !isFullyConfirmed || isBackendCompleted || isOverCap || isExpired) return; setIsPollingBackend(true); let timer: NodeJS.Timeout | undefined; let cancelled = false; @@ -113,6 +116,13 @@ export const useTransactionConfirmation = ({ setIsPollingBackend(false); return; } + // expired is a terminal state — the payment window has closed and the + // intent will never transition to completed. Stop polling immediately. + if (intent.status === 'expired') { + setIsExpired(true); + setIsPollingBackend(false); + return; + } } catch { // ignore and retry } @@ -126,7 +136,7 @@ export const useTransactionConfirmation = ({ cancelled = true; if (timer) clearTimeout(timer); }; - }, [api, intentId, isFullyConfirmed, isBackendCompleted, isOverCap, queryClient]); + }, [api, intentId, isFullyConfirmed, isBackendCompleted, isOverCap, isExpired, queryClient]); return { isWaitingReceipt, @@ -136,6 +146,7 @@ export const useTransactionConfirmation = ({ isPollingBackend, isBackendCompleted, isOverCap, + isExpired, waitError, }; }; From d0f64d9db9e8a11644d7ea472e634380015c790c Mon Sep 17 00:00:00 2001 From: Emil F Date: Tue, 24 Mar 2026 07:36:49 +0400 Subject: [PATCH 45/78] fix(frontend): use Math.floor in daysUntilExpiry so expiry-today shows 0 Math.ceil caused credits expiring in < 1 day to display "1 day remaining" instead of "0 days". Switching to Math.floor means callers see the number of whole days remaining, so credits expiring today correctly return 0 and the UI can render "expires today" rather than an off-by-one value. Also adds a spec case covering the < 1 day (same-day expiry) scenario. Co-Authored-By: Claude Sonnet 4.6 --- apps/frontend/__tests__/unit/utils/credits.spec.ts | 14 +++++++++++--- apps/frontend/src/utils/credits.ts | 7 ++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/frontend/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts index 8d9977490..f0c789a96 100644 --- a/apps/frontend/__tests__/unit/utils/credits.spec.ts +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -71,12 +71,20 @@ describe('daysUntilExpiry', () => { expect(daysUntilExpiry(expiresAt)).toBe(1) }) - it('rounds up partial days to the nearest whole day', () => { + it('rounds down partial days so credits expiring today show 0', () => { const now = new Date('2026-01-01T00:00:00Z') jest.setSystemTime(now) - // 1.5 days → ceil → 2 + // 1.5 days → floor → 1 const expiresAt = new Date('2026-01-02T12:00:00Z') - expect(daysUntilExpiry(expiresAt)).toBe(2) + expect(daysUntilExpiry(expiresAt)).toBe(1) + }) + + it('returns 0 when credits expire later today (< 1 whole day remaining)', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + // 0.5 days → floor → 0 + const expiresAt = new Date('2026-01-01T12:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(0) }) it('returns 0 for a past expiry date (clamped)', () => { diff --git a/apps/frontend/src/utils/credits.ts b/apps/frontend/src/utils/credits.ts index a2616dcf5..c630e9073 100644 --- a/apps/frontend/src/utils/credits.ts +++ b/apps/frontend/src/utils/credits.ts @@ -18,14 +18,15 @@ export const isPackageOverCap = ( }; /** - * Computes the number of days remaining until `expiresAt`, rounding up to the - * nearest whole day. Returns null when `expiresAt` is not provided. + * Computes the number of whole days remaining until `expiresAt`, rounding + * down so that credits expiring today (< 1 day remaining) return 0. + * Returns null when `expiresAt` is not provided. */ export const daysUntilExpiry = (expiresAt: Date | null): number | null => { if (!expiresAt) return null; return Math.max( 0, - Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)), + Math.floor((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)), ); }; From 20f6a3868f3b39a1e95699256171182956e59aeb Mon Sep 17 00:00:00 2001 From: Emil F Date: Tue, 24 Mar 2026 08:22:54 +0400 Subject: [PATCH 46/78] fix(frontend): handle expired intent state in Step3_TransferTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step3 destructured isOverCap but not isExpired from useTransactionConfirmation, leaving users stuck on a disabled "Finalizing…" button with no explanation when an intent expired. Mirror the isOverCap pattern: destructure isExpired, show a red error banner, and disable the Continue button. Made-with: Cursor --- .../PurchaseCredits/steps/Step3_TransferTokens.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx b/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx index 9fc2dba0f..8f729b3f3 100644 --- a/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx +++ b/apps/frontend/src/components/views/PurchaseCredits/steps/Step3_TransferTokens.tsx @@ -41,6 +41,7 @@ export const PurchaseStep3TransferTokens = ({ isPollingBackend, isBackendCompleted, isOverCap, + isExpired, waitError, } = useTransactionConfirmation({ txHash, @@ -181,7 +182,7 @@ export const PurchaseStep3TransferTokens = ({
)} - {isFullyConfirmed && !isOverCap && ( + {isFullyConfirmed && !isOverCap && !isExpired && (
{isPollingBackend ? 'Waiting for backend to update credits…' @@ -196,15 +197,22 @@ export const PurchaseStep3TransferTokens = ({ assistance.
)} + {isExpired && ( +
+ Payment expired. The payment window for this + transaction has closed and credits will not be applied. Please + try again or contact support for assistance. +
+ )} {waitError && (
{waitError.message}
)}
From 7cb8de79826a5f5b460b9c5131e754efedf97e74 Mon Sep 17 00:00:00 2001 From: Emil F Date: Tue, 24 Mar 2026 08:37:37 +0400 Subject: [PATCH 47/78] fix(frontend): detect expired intents via HTTP 410 instead of unreachable status check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's getIntent returns a GoneError (HTTP 410) for expired intents before the status string reaches the response body. The previous `intent.status === 'expired'` check was dead code — the fetch threw, the catch block silently swallowed the error, and polling looped forever. Now getIntent throws an ApiError carrying the HTTP status, and the catch block detects 410 to set isExpired. Made-with: Cursor --- .../hooks/useTransactionConfirmation.spec.ts | 115 ++++++++++++------ .../src/hooks/useTransactionConfirmation.ts | 12 +- apps/frontend/src/services/api.ts | 15 ++- 3 files changed, 98 insertions(+), 44 deletions(-) diff --git a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts index ce6718f4e..91de94c79 100644 --- a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts +++ b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts @@ -1,30 +1,45 @@ /** * Unit tests for the intent-polling decision logic extracted from * useTransactionConfirmation. These tests verify the correct terminal-state - * handling for completed and over_cap intents without requiring a React - * rendering environment. + * handling for completed, over_cap, and expired intents without requiring a + * React rendering environment. + * + * IMPORTANT: The backend returns HTTP 410 Gone for expired intents instead of + * an `{ status: 'expired' }` response body. The `getIntent` call throws an + * `ApiError(410, …)` before the caller ever sees a status string. The polling + * loop detects this via the catch block, not via `intent.status === 'expired'`. */ // --------------------------------------------------------------------------- -// Polling decision logic (extracted inline to test independently) +// Minimal ApiError replica (mirrors apps/frontend/src/services/api.ts) // --------------------------------------------------------------------------- -type IntentStatus = 'pending' | 'confirmed' | 'completed' | 'failed' | 'expired' | 'over_cap' +class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + this.name = 'ApiError' + } +} + +// --------------------------------------------------------------------------- +// Polling decision logic (mirrors the try/catch in the `poll` callback) +// --------------------------------------------------------------------------- + +type IntentStatus = 'pending' | 'confirmed' | 'completed' | 'failed' | 'over_cap' interface PollResult { - /** True if the backend has successfully applied credits. */ completed: boolean - /** True if the intent hit the per-user cap and credits were NOT applied. */ overCap: boolean - /** True if the intent has expired and credits will never be applied. */ expired: boolean - /** True if polling should continue on the next iteration. */ shouldContinue: boolean } /** - * Pure function mirroring the decision branch inside the `poll` callback of - * useTransactionConfirmation. This is what we test here. + * Mirrors the try-branch: evaluates the status string returned in the + * response body when the API call succeeds (HTTP 2xx). */ function evaluateIntentStatus(status: IntentStatus): PollResult { if (status === 'completed') { @@ -33,19 +48,27 @@ function evaluateIntentStatus(status: IntentStatus): PollResult { if (status === 'over_cap') { return { completed: false, overCap: true, expired: false, shouldContinue: false } } - if (status === 'expired') { + return { completed: false, overCap: false, expired: false, shouldContinue: true } +} + +/** + * Mirrors the catch-branch: evaluates the thrown error. The backend returns + * HTTP 410 for expired intents, so this is the only path through which + * `expired` can become true. + */ +function evaluatePollError(error: unknown): PollResult { + if (error instanceof ApiError && error.status === 410) { return { completed: false, overCap: false, expired: true, shouldContinue: false } } - // Any other status (pending, confirmed, failed) → keep polling return { completed: false, overCap: false, expired: false, shouldContinue: true } } // --------------------------------------------------------------------------- -// Tests +// Tests — successful response branch (try) // --------------------------------------------------------------------------- -describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () => { - it('marks completed=true and stops polling when status is "completed"', () => { +describe('evaluateIntentStatus (try branch)', () => { + it('marks completed and stops polling for "completed"', () => { const result = evaluateIntentStatus('completed') expect(result.completed).toBe(true) expect(result.overCap).toBe(false) @@ -53,7 +76,7 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = expect(result.shouldContinue).toBe(false) }) - it('marks overCap=true and stops polling when status is "over_cap"', () => { + it('marks overCap and stops polling for "over_cap"', () => { const result = evaluateIntentStatus('over_cap') expect(result.completed).toBe(false) expect(result.overCap).toBe(true) @@ -61,42 +84,58 @@ describe('evaluateIntentStatus (useTransactionConfirmation polling logic)', () = expect(result.shouldContinue).toBe(false) }) - it('marks expired=true and stops polling when status is "expired"', () => { - const result = evaluateIntentStatus('expired') + it('continues polling for "pending"', () => { + const result = evaluateIntentStatus('pending') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('continues polling for "confirmed"', () => { + const result = evaluateIntentStatus('confirmed') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('continues polling for "failed"', () => { + const result = evaluateIntentStatus('failed') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('over_cap and completed are mutually exclusive', () => { + expect(evaluateIntentStatus('over_cap').completed).toBe(false) + expect(evaluateIntentStatus('completed').overCap).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Tests — error branch (catch) +// --------------------------------------------------------------------------- + +describe('evaluatePollError (catch branch — expired intent detection)', () => { + it('marks expired and stops polling on ApiError with status 410', () => { + const error = new ApiError(410, 'Intent has expired') + const result = evaluatePollError(error) + expect(result.expired).toBe(true) expect(result.completed).toBe(false) expect(result.overCap).toBe(false) - expect(result.expired).toBe(true) expect(result.shouldContinue).toBe(false) }) - it('continues polling when status is "pending"', () => { - const result = evaluateIntentStatus('pending') - expect(result.completed).toBe(false) - expect(result.overCap).toBe(false) + it('continues polling on ApiError with non-410 status (e.g. 500)', () => { + const error = new ApiError(500, 'Internal server error') + const result = evaluatePollError(error) expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) - it('continues polling when status is "confirmed"', () => { - const result = evaluateIntentStatus('confirmed') - expect(result.completed).toBe(false) - expect(result.overCap).toBe(false) + it('continues polling on generic Error (network failure, etc.)', () => { + const error = new Error('fetch failed') + const result = evaluatePollError(error) expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) - it('continues polling when status is "failed" (surface through continued polling)', () => { - const result = evaluateIntentStatus('failed') - expect(result.completed).toBe(false) - expect(result.overCap).toBe(false) + it('continues polling on non-Error thrown value', () => { + const result = evaluatePollError('unexpected string') expect(result.expired).toBe(false) expect(result.shouldContinue).toBe(true) }) - - it('over_cap is NOT the same as completed — they are mutually exclusive', () => { - const overCapResult = evaluateIntentStatus('over_cap') - const completedResult = evaluateIntentStatus('completed') - expect(overCapResult.completed).toBe(false) - expect(completedResult.overCap).toBe(false) - }) }) diff --git a/apps/frontend/src/hooks/useTransactionConfirmation.ts b/apps/frontend/src/hooks/useTransactionConfirmation.ts index 3c43d0d04..5ab86d3a0 100644 --- a/apps/frontend/src/hooks/useTransactionConfirmation.ts +++ b/apps/frontend/src/hooks/useTransactionConfirmation.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { usePublicClient, useWaitForTransactionReceipt } from 'wagmi'; import { type Hash } from 'viem'; import { useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '../services/api'; interface UseTransactionConfirmationProps { txHash: Hash | undefined; @@ -116,15 +117,16 @@ export const useTransactionConfirmation = ({ setIsPollingBackend(false); return; } - // expired is a terminal state — the payment window has closed and the - // intent will never transition to completed. Stop polling immediately. - if (intent.status === 'expired') { + } catch (error) { + // The backend returns 410 Gone for expired intents (isIntentExpired + // triggers a GoneError before the status string reaches the client). + // Detect this via the ApiError status and stop polling. + if (error instanceof ApiError && error.status === 410) { setIsExpired(true); setIsPollingBackend(false); return; } - } catch { - // ignore and retry + // Any other error — ignore and retry } if (!cancelled) { timer = setTimeout(poll, 2000); diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 79813aef5..72d609c1d 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -38,6 +38,16 @@ export type ExpiringCreditBatch = { import { getAuthSession } from 'utils/auth'; import { uploadFileContent } from 'utils/file'; +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + export interface UploadResponse { cid: string; } @@ -111,7 +121,10 @@ export const createApiService = ({ }); if (!response.ok) { - throw new Error(`Network response was not ok: ${response.statusText}`); + throw new ApiError( + response.status, + `Network response was not ok: ${response.statusText}`, + ); } return response.json() as Promise; From fb4f1422b139a530263723519aefd04a6ec31471 Mon Sep 17 00:00:00 2001 From: Emil F Date: Wed, 25 Mar 2026 07:47:46 +0400 Subject: [PATCH 48/78] fix(credits): coerce hasBuyCreditsFeature to boolean for TanStack Query features.buyCredits is undefined before the store is populated, causing the useQuery enabled option to receive undefined instead of false. TanStack Query v5 treats enabled: undefined as enabled, firing the query prematurely. Made-with: Cursor --- apps/frontend/src/components/views/CreditHistory/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/views/CreditHistory/index.tsx b/apps/frontend/src/components/views/CreditHistory/index.tsx index 2d71c2c74..9f6ced36a 100644 --- a/apps/frontend/src/components/views/CreditHistory/index.tsx +++ b/apps/frontend/src/components/views/CreditHistory/index.tsx @@ -155,7 +155,7 @@ export const CreditHistoryView = () => { const { account, features } = useUserStore(); const hasBuyCreditsFeature = - features.buyCredits && account?.model === AccountModel.OneOff; + !!features.buyCredits && account?.model === AccountModel.OneOff; const purchaseHref = `/${network.id}/drive/purchase`; From 5284092174c7285c5a72cb9176c174e7f38d26e8 Mon Sep 17 00:00:00 2001 From: Jim Counter Date: Fri, 27 Mar 2026 12:15:47 +0000 Subject: [PATCH 49/78] feat: add in-app banner notification system Implements a full-stack banner notification system per #624. Admins can create, schedule, and manage banners with configurable criticality (info/warning/critical), dismissability, and acknowledgement tracking. Users see active banners between the navbar and content area, ordered by criticality. Closes #624 Co-Authored-By: Claude Opus 4.6 --- .../migrations/20260327000000-banners.js | 53 ++++ .../sqls/20260327000000-banners-down.sql | 2 + .../sqls/20260327000000-banners-up.sql | 35 +++ apps/backend/src/app/apis/frontend.ts | 2 + apps/backend/src/app/controllers/banners.ts | 249 +++++++++++++++ apps/backend/src/core/banners.ts | 182 +++++++++++ apps/backend/src/errors/index.ts | 16 + .../infrastructure/repositories/banners.ts | 297 ++++++++++++++++++ .../app/[chain]/drive/admin/banners/page.tsx | 12 + .../frontend/src/app/[chain]/drive/layout.tsx | 2 + .../BannerNotifications/BannerItem.tsx | 75 +++++ .../organisms/BannerNotifications/index.tsx | 49 +++ .../components/organisms/SideNavBar/items.ts | 7 + .../views/BannerAdmin/BannerForm.tsx | 227 +++++++++++++ .../components/views/BannerAdmin/index.tsx | 214 +++++++++++++ apps/frontend/src/globalStates/banners.ts | 21 ++ apps/frontend/src/services/api.ts | 175 +++++++++++ packages/models/src/common/banner.ts | 38 +++ packages/models/src/common/index.ts | 3 +- packages/ui/src/constants/routes.ts | 1 + 20 files changed, 1659 insertions(+), 1 deletion(-) create mode 100644 apps/backend/migrations/20260327000000-banners.js create mode 100644 apps/backend/migrations/sqls/20260327000000-banners-down.sql create mode 100644 apps/backend/migrations/sqls/20260327000000-banners-up.sql create mode 100644 apps/backend/src/app/controllers/banners.ts create mode 100644 apps/backend/src/core/banners.ts create mode 100644 apps/backend/src/infrastructure/repositories/banners.ts create mode 100644 apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx create mode 100644 apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx create mode 100644 apps/frontend/src/components/organisms/BannerNotifications/index.tsx create mode 100644 apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx create mode 100644 apps/frontend/src/components/views/BannerAdmin/index.tsx create mode 100644 apps/frontend/src/globalStates/banners.ts create mode 100644 packages/models/src/common/banner.ts diff --git a/apps/backend/migrations/20260327000000-banners.js b/apps/backend/migrations/20260327000000-banners.js new file mode 100644 index 000000000..f863a538a --- /dev/null +++ b/apps/backend/migrations/20260327000000-banners.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', + '20260327000000-banners-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', + '20260327000000-banners-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/20260327000000-banners-down.sql b/apps/backend/migrations/sqls/20260327000000-banners-down.sql new file mode 100644 index 000000000..0404fd0b9 --- /dev/null +++ b/apps/backend/migrations/sqls/20260327000000-banners-down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.banner_interactions; +DROP TABLE IF EXISTS public.banners; diff --git a/apps/backend/migrations/sqls/20260327000000-banners-up.sql b/apps/backend/migrations/sqls/20260327000000-banners-up.sql new file mode 100644 index 000000000..899ff8448 --- /dev/null +++ b/apps/backend/migrations/sqls/20260327000000-banners-up.sql @@ -0,0 +1,35 @@ +-- Banners table +CREATE TABLE public.banners ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + title text NOT NULL, + body text NOT NULL, + criticality text NOT NULL DEFAULT 'info', + dismissable boolean NOT NULL DEFAULT true, + requires_acknowledgement boolean NOT NULL DEFAULT false, + display_start timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + display_end timestamp NULL, + active boolean NOT NULL DEFAULT true, + created_by text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT banners_pkey PRIMARY KEY (id), + CONSTRAINT banners_criticality_check CHECK (criticality IN ('info', 'warning', 'critical')) +); + +CREATE INDEX idx_banners_active ON public.banners (active, display_start, display_end); + +-- Banner interactions table +CREATE TABLE public.banner_interactions ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + user_id text NOT NULL, + banner_id text NOT NULL, + interaction_type text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT banner_interactions_pkey PRIMARY KEY (id), + CONSTRAINT banner_interactions_banner_fk FOREIGN KEY (banner_id) REFERENCES public.banners(id) ON DELETE CASCADE, + CONSTRAINT banner_interactions_type_check CHECK (interaction_type IN ('acknowledged', 'dismissed')), + CONSTRAINT banner_interactions_unique UNIQUE (user_id, banner_id, interaction_type) +); + +CREATE INDEX idx_banner_interactions_user ON public.banner_interactions (user_id); +CREATE INDEX idx_banner_interactions_banner ON public.banner_interactions (banner_id); diff --git a/apps/backend/src/app/apis/frontend.ts b/apps/backend/src/app/apis/frontend.ts index 5dcb67432..d314e7f48 100644 --- a/apps/backend/src/app/apis/frontend.ts +++ b/apps/backend/src/app/apis/frontend.ts @@ -11,6 +11,7 @@ import { createLogger } from '../../infrastructure/drivers/logger.js' import { docsController } from '../controllers/docs.js' import { intentsController } from '../controllers/intents.js' import { creditsController } from '../controllers/credits.js' +import { bannersController } from '../controllers/banners.js' import { featuresController } from '../controllers/features.js' import { featureFlagMiddleware } from '../../core/featureFlags/express.js' import { IntentsUseCases } from '../../core/users/intents.js' @@ -74,6 +75,7 @@ const createServer = async () => { ) app.use('/intents', featureFlagMiddleware('buyCredits'), intentsController) app.use('/credits', featureFlagMiddleware('buyCredits'), creditsController) + app.use('/banners', bannersController) app.use('/features', featuresController) app.use('/docs', docsController) diff --git a/apps/backend/src/app/controllers/banners.ts b/apps/backend/src/app/controllers/banners.ts new file mode 100644 index 000000000..56cf41372 --- /dev/null +++ b/apps/backend/src/app/controllers/banners.ts @@ -0,0 +1,249 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { BannersUseCases } from '../../core/banners.js' +import { + handleInternalError, + handleInternalErrorResult, +} from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' +import { BannerInteractionType } from '@auto-drive/models' + +export const bannersController = Router() + +// --------------------------------------------------------------------------- +// GET /banners/active +// Returns active banners for the authenticated user, filtered by interactions. +// --------------------------------------------------------------------------- + +bannersController.get( + '/active', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + BannersUseCases.getActiveBannersForUser(user), + 'Failed to get active banners', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/:id/interact +// Records a user interaction (acknowledge or dismiss) with a banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/:id/interact', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { type } = req.body as { type: string } + if ( + type !== BannerInteractionType.Acknowledged && + type !== BannerInteractionType.Dismissed + ) { + res.status(400).json({ error: 'Invalid interaction type' }) + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.recordInteraction(user, req.params.id, type), + 'Failed to record banner interaction', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(204).send() + }), +) + +// --------------------------------------------------------------------------- +// GET /banners/admin +// Admin-only: returns all banners. +// --------------------------------------------------------------------------- + +bannersController.get( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.getAllBanners(user), + 'Failed to get all banners', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/admin +// Admin-only: create a new banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart, + displayEnd, + active, + } = req.body + + const result = await handleInternalErrorResult( + BannersUseCases.createBanner(user, { + title, + body, + criticality, + dismissable: dismissable ?? true, + requiresAcknowledgement: requiresAcknowledgement ?? false, + displayStart: displayStart ? new Date(displayStart) : new Date(), + displayEnd: displayEnd ? new Date(displayEnd) : null, + active: active ?? true, + }), + 'Failed to create banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(201).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// PUT /banners/admin/:id +// Admin-only: update an existing banner. +// --------------------------------------------------------------------------- + +bannersController.put( + '/admin/:id', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart, + displayEnd, + active, + } = req.body + + const result = await handleInternalErrorResult( + BannersUseCases.updateBanner(user, req.params.id, { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart: displayStart ? new Date(displayStart) : undefined, + displayEnd: displayEnd !== undefined + ? displayEnd + ? new Date(displayEnd) + : null + : undefined, + active, + }), + 'Failed to update banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/admin/:id/toggle +// Admin-only: activate or deactivate a banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/admin/:id/toggle', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { active } = req.body as { active: boolean } + + const result = await handleInternalErrorResult( + BannersUseCases.toggleBannerActive(user, req.params.id, active), + 'Failed to toggle banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// GET /banners/admin/:id/stats +// Admin-only: get banner with acknowledgement/dismissal stats. +// --------------------------------------------------------------------------- + +bannersController.get( + '/admin/:id/stats', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.getBannerWithStats(user, req.params.id), + 'Failed to get banner stats', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) diff --git a/apps/backend/src/core/banners.ts b/apps/backend/src/core/banners.ts new file mode 100644 index 000000000..857d9ca29 --- /dev/null +++ b/apps/backend/src/core/banners.ts @@ -0,0 +1,182 @@ +import { + Banner, + BannerCriticality, + BannerInteractionType, + BannerWithStats, + User, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { bannersRepository } from '../infrastructure/repositories/banners.js' +import { + BadRequestError, + ForbiddenError, + NotFoundError, +} from '../errors/index.js' +import { err, ok, Result } from 'neverthrow' +import { createLogger } from '../infrastructure/drivers/logger.js' + +const logger = createLogger('BannersUseCases') + +// --------------------------------------------------------------------------- +// getActiveBannersForUser +// --------------------------------------------------------------------------- + +const getActiveBannersForUser = async ( + user: UserWithOrganization, +): Promise => { + return bannersRepository.getActiveBannersForUser(user.publicId) +} + +// --------------------------------------------------------------------------- +// recordInteraction +// --------------------------------------------------------------------------- + +const recordInteraction = async ( + user: UserWithOrganization, + bannerId: string, + type: BannerInteractionType, +): Promise> => { + const banner = await bannersRepository.getBannerById(bannerId) + if (!banner) { + return err(new NotFoundError('Banner not found')) + } + + if (type === BannerInteractionType.Dismissed && !banner.dismissable) { + return err(new BadRequestError('Banner is not dismissable')) + } + + if ( + type === BannerInteractionType.Acknowledged && + !banner.requiresAcknowledgement + ) { + return err( + new BadRequestError('Banner does not require acknowledgement'), + ) + } + + await bannersRepository.createInteraction(user.publicId, bannerId, type) + return ok(undefined) +} + +// --------------------------------------------------------------------------- +// Admin: createBanner +// --------------------------------------------------------------------------- + +type CreateBannerParams = { + title: string + body: string + criticality: BannerCriticality + dismissable: boolean + requiresAcknowledgement: boolean + displayStart: Date + displayEnd: Date | null + active: boolean +} + +const createBanner = async ( + executor: User, + params: CreateBannerParams, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to create banner', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.createBanner({ + ...params, + createdBy: executor.publicId, + }) + return ok(banner) +} + +// --------------------------------------------------------------------------- +// Admin: updateBanner +// --------------------------------------------------------------------------- + +type UpdateBannerParams = { + title?: string + body?: string + criticality?: BannerCriticality + dismissable?: boolean + requiresAcknowledgement?: boolean + displayStart?: Date + displayEnd?: Date | null + active?: boolean +} + +const updateBanner = async ( + executor: User, + bannerId: string, + params: UpdateBannerParams, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.updateBanner(bannerId, params) + if (!banner) { + return err(new NotFoundError('Banner not found')) + } + + return ok(banner) +} + +// --------------------------------------------------------------------------- +// Admin: toggleBannerActive +// --------------------------------------------------------------------------- + +const toggleBannerActive = async ( + executor: User, + bannerId: string, + active: boolean, +): Promise> => { + return updateBanner(executor, bannerId, { active }) +} + +// --------------------------------------------------------------------------- +// Admin: getAllBanners +// --------------------------------------------------------------------------- + +const getAllBanners = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banners = await bannersRepository.getAllBanners() + return ok(banners) +} + +// --------------------------------------------------------------------------- +// Admin: getBannerWithStats +// --------------------------------------------------------------------------- + +const getBannerWithStats = async ( + executor: User, + bannerId: string, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.getBannerWithStats(bannerId) + if (!banner) { + return err(new NotFoundError('Banner not found')) + } + + return ok(banner) +} + +export const BannersUseCases = { + getActiveBannersForUser, + recordInteraction, + createBanner, + updateBanner, + toggleBannerActive, + getAllBanners, + getBannerWithStats, +} diff --git a/apps/backend/src/errors/index.ts b/apps/backend/src/errors/index.ts index 0d9de6496..e65d9bb51 100644 --- a/apps/backend/src/errors/index.ts +++ b/apps/backend/src/errors/index.ts @@ -74,6 +74,22 @@ export class ForbiddenError extends HttpError { } } +export class NotFoundError extends HttpError { + static readonly statusCode = 404 + constructor(message: string) { + super(NotFoundError.statusCode, message) + this.name = 'NotFoundError' + } +} + +export class BadRequestError extends HttpError { + static readonly statusCode = 400 + constructor(message: string) { + super(BadRequestError.statusCode, message) + this.name = 'BadRequestError' + } +} + // 409 Conflict — request is valid but the resource is in the wrong state for // the operation (e.g. trying to reprocess an intent that is not OVER_CAP). export class ConflictError extends HttpError { diff --git a/apps/backend/src/infrastructure/repositories/banners.ts b/apps/backend/src/infrastructure/repositories/banners.ts new file mode 100644 index 000000000..197708641 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/banners.ts @@ -0,0 +1,297 @@ +import { + Banner, + BannerInteraction, + BannerInteractionType, + BannerCriticality, + BannerWithStats, +} from '@auto-drive/models' +import { getDatabase } from '../drivers/pg.js' + +// --------------------------------------------------------------------------- +// Internal DB row types +// --------------------------------------------------------------------------- + +type DBBanner = { + id: string + title: string + body: string + criticality: string + dismissable: boolean + requires_acknowledgement: boolean + display_start: Date + display_end: Date | null + active: boolean + created_by: string + created_at: Date + updated_at: Date +} + +type DBBannerInteraction = { + id: string + user_id: string + banner_id: string + interaction_type: string + created_at: Date +} + +const mapBannerRow = (row: DBBanner): Banner => ({ + id: row.id, + title: row.title, + body: row.body, + criticality: row.criticality as BannerCriticality, + dismissable: row.dismissable, + requiresAcknowledgement: row.requires_acknowledgement, + displayStart: row.display_start, + displayEnd: row.display_end, + active: row.active, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +const mapInteractionRow = (row: DBBannerInteraction): BannerInteraction => ({ + id: row.id, + userId: row.user_id, + bannerId: row.banner_id, + interactionType: row.interaction_type as BannerInteractionType, + createdAt: row.created_at, +}) + +// --------------------------------------------------------------------------- +// createBanner +// --------------------------------------------------------------------------- + +type CreateBannerParams = { + title: string + body: string + criticality: BannerCriticality + dismissable: boolean + requiresAcknowledgement: boolean + displayStart: Date + displayEnd: Date | null + active: boolean + createdBy: string +} + +const createBanner = async (params: CreateBannerParams): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO banners ( + title, body, criticality, dismissable, requires_acknowledgement, + display_start, display_end, active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + params.title, + params.body, + params.criticality, + params.dismissable, + params.requiresAcknowledgement, + params.displayStart, + params.displayEnd, + params.active, + params.createdBy, + ], + ) + return mapBannerRow(result.rows[0]) +} + +// --------------------------------------------------------------------------- +// updateBanner +// --------------------------------------------------------------------------- + +type UpdateBannerParams = { + title?: string + body?: string + criticality?: BannerCriticality + dismissable?: boolean + requiresAcknowledgement?: boolean + displayStart?: Date + displayEnd?: Date | null + active?: boolean +} + +const updateBanner = async ( + id: string, + params: UpdateBannerParams, +): Promise => { + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (params.title !== undefined) { + fields.push(`title = $${idx++}`) + values.push(params.title) + } + if (params.body !== undefined) { + fields.push(`body = $${idx++}`) + values.push(params.body) + } + if (params.criticality !== undefined) { + fields.push(`criticality = $${idx++}`) + values.push(params.criticality) + } + if (params.dismissable !== undefined) { + fields.push(`dismissable = $${idx++}`) + values.push(params.dismissable) + } + if (params.requiresAcknowledgement !== undefined) { + fields.push(`requires_acknowledgement = $${idx++}`) + values.push(params.requiresAcknowledgement) + } + if (params.displayStart !== undefined) { + fields.push(`display_start = $${idx++}`) + values.push(params.displayStart) + } + if (params.displayEnd !== undefined) { + fields.push(`display_end = $${idx++}`) + values.push(params.displayEnd) + } + if (params.active !== undefined) { + fields.push(`active = $${idx++}`) + values.push(params.active) + } + + if (fields.length === 0) return null + + fields.push('updated_at = NOW()') + values.push(id) + + const db = await getDatabase() + const result = await db.query( + `UPDATE banners SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values, + ) + + return result.rows[0] ? mapBannerRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getBannerById +// --------------------------------------------------------------------------- + +const getBannerById = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM banners WHERE id = $1', + [id], + ) + return result.rows[0] ? mapBannerRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getAllBanners — admin listing +// --------------------------------------------------------------------------- + +const getAllBanners = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM banners ORDER BY created_at DESC', + ) + return result.rows.map(mapBannerRow) +} + +// --------------------------------------------------------------------------- +// getActiveBannersForUser +// Returns currently active banners that the user has not yet interacted with. +// --------------------------------------------------------------------------- + +const getActiveBannersForUser = async ( + userId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT b.* FROM banners b + LEFT JOIN banner_interactions bi + ON bi.banner_id = b.id AND bi.user_id = $1 + WHERE b.active = true + AND b.display_start <= NOW() + AND (b.display_end IS NULL OR b.display_end >= NOW()) + AND bi.id IS NULL + ORDER BY + CASE b.criticality + WHEN 'critical' THEN 0 + WHEN 'warning' THEN 1 + WHEN 'info' THEN 2 + END`, + [userId], + ) + return result.rows.map(mapBannerRow) +} + +// --------------------------------------------------------------------------- +// createInteraction — idempotent via ON CONFLICT DO NOTHING +// --------------------------------------------------------------------------- + +const createInteraction = async ( + userId: string, + bannerId: string, + interactionType: BannerInteractionType, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO banner_interactions (user_id, banner_id, interaction_type) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, banner_id, interaction_type) DO NOTHING + RETURNING *`, + [userId, bannerId, interactionType], + ) + return result.rows[0] ? mapInteractionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getBannerWithStats — banner + interaction counts for admin view +// --------------------------------------------------------------------------- + +const getBannerWithStats = async ( + id: string, +): Promise => { + const db = await getDatabase() + + const bannerResult = await db.query( + 'SELECT * FROM banners WHERE id = $1', + [id], + ) + if (!bannerResult.rows[0]) return null + + const statsResult = await db.query<{ + interaction_type: string + count: string + }>( + `SELECT interaction_type, COUNT(*) as count + FROM banner_interactions + WHERE banner_id = $1 + GROUP BY interaction_type`, + [id], + ) + + let acknowledgementCount = 0 + let dismissalCount = 0 + for (const row of statsResult.rows) { + if (row.interaction_type === 'acknowledged') { + acknowledgementCount = Number(row.count) + } else if (row.interaction_type === 'dismissed') { + dismissalCount = Number(row.count) + } + } + + return { + ...mapBannerRow(bannerResult.rows[0]), + acknowledgementCount, + dismissalCount, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const bannersRepository = { + createBanner, + updateBanner, + getBannerById, + getAllBanners, + getActiveBannersForUser, + createInteraction, + getBannerWithStats, +} diff --git a/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx b/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx new file mode 100644 index 000000000..49646c25d --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx @@ -0,0 +1,12 @@ +import { BannerAdmin } from '@/components/views/BannerAdmin'; +import { UserProtectedLayout } from '../../../../../components/layouts/UserProtectedLayout'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/[chain]/drive/layout.tsx b/apps/frontend/src/app/[chain]/drive/layout.tsx index 497c91956..6acbb1f26 100644 --- a/apps/frontend/src/app/[chain]/drive/layout.tsx +++ b/apps/frontend/src/app/[chain]/drive/layout.tsx @@ -9,6 +9,7 @@ import { SidebarProvider } from '@/components/molecules/Sidebar'; import { SideNavbar } from 'frontend/src/components/organisms/SideNavBar'; import { SessionEnsurer } from '@/components/atoms/SessionEnsurer'; import { AutomaticLoginWrapper } from '../../../components/atoms/AutomaticLoginWrapper'; +import { BannerNotifications } from '@/components/organisms/BannerNotifications'; export default function AppLayout({ children, @@ -26,6 +27,7 @@ export default function AppLayout({
+
diff --git a/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx b/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx new file mode 100644 index 000000000..61ead61a7 --- /dev/null +++ b/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Banner, BannerCriticality, BannerInteractionType } from '@auto-drive/models'; +import { X, AlertTriangle, AlertCircle, Info } from 'lucide-react'; +import { useCallback } from 'react'; + +const criticalityStyles: Record< + BannerCriticality, + { container: string; icon: typeof Info } +> = { + [BannerCriticality.Info]: { + container: + 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200', + icon: Info, + }, + [BannerCriticality.Warning]: { + container: + 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200', + icon: AlertTriangle, + }, + [BannerCriticality.Critical]: { + container: + 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200', + icon: AlertCircle, + }, +}; + +interface BannerItemProps { + banner: Banner; + onInteract?: (bannerId: string, type: BannerInteractionType) => void; + preview?: boolean; +} + +export const BannerItem = ({ banner, onInteract, preview }: BannerItemProps) => { + const style = criticalityStyles[banner.criticality]; + const IconComponent = style.icon; + + const handleDismiss = useCallback(() => { + onInteract?.(banner.id, BannerInteractionType.Dismissed); + }, [banner.id, onInteract]); + + const handleAcknowledge = useCallback(() => { + onInteract?.(banner.id, BannerInteractionType.Acknowledged); + }, [banner.id, onInteract]); + + return ( +
+ +
+

{banner.title}

+

{banner.body}

+ {banner.requiresAcknowledgement && !preview && ( + + )} +
+ {banner.dismissable && !preview && ( + + )} +
+ ); +}; diff --git a/apps/frontend/src/components/organisms/BannerNotifications/index.tsx b/apps/frontend/src/components/organisms/BannerNotifications/index.tsx new file mode 100644 index 000000000..50273ab1f --- /dev/null +++ b/apps/frontend/src/components/organisms/BannerNotifications/index.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useCallback, useEffect } from 'react'; +import { BannerInteractionType } from '@auto-drive/models'; +import { useNetwork } from 'contexts/network'; +import { useBannerStore } from 'globalStates/banners'; +import { useUserStore } from 'globalStates/user'; +import { BannerItem } from './BannerItem'; + +export const BannerNotifications = () => { + const { api } = useNetwork(); + const user = useUserStore((s) => s.user); + const { banners, setBanners, removeBanner } = useBannerStore(); + + useEffect(() => { + if (!user) { + setBanners([]); + return; + } + + api.getActiveBanners().then(setBanners).catch(() => setBanners([])); + }, [api, user, setBanners]); + + const handleInteract = useCallback( + async (bannerId: string, type: BannerInteractionType) => { + try { + await api.interactWithBanner(bannerId, type); + removeBanner(bannerId); + } catch { + // Silently fail — banner stays visible + } + }, + [api, removeBanner], + ); + + if (banners.length === 0) return null; + + return ( +
+ {banners.map((banner) => ( + + ))} +
+ ); +}; diff --git a/apps/frontend/src/components/organisms/SideNavBar/items.ts b/apps/frontend/src/components/organisms/SideNavBar/items.ts index b1cb8fc19..69a915e79 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/items.ts +++ b/apps/frontend/src/components/organisms/SideNavBar/items.ts @@ -6,6 +6,7 @@ import { UserIcon, CodeXmlIcon, SettingsIcon, + MegaphoneIcon, } from 'lucide-react'; import { NetworkId, ROUTES } from '@auto-drive/ui'; import { SidebarSection } from './SideNavBarContent'; @@ -71,6 +72,12 @@ export const SIDEBAR_DEFINITION: SidebarSection[] = [ label: 'Admin', requiresSession: true, }, + { + href: (networkId: NetworkId) => ROUTES.adminBanners(networkId), + icon: MegaphoneIcon, + label: 'Banners', + requiresSession: true, + }, ], }, ]; diff --git a/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx b/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx new file mode 100644 index 000000000..211c88a36 --- /dev/null +++ b/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { Banner, BannerCriticality } from '@auto-drive/models'; +import { useCallback, useState } from 'react'; +import { BannerItem } from '../../organisms/BannerNotifications/BannerItem'; + +type BannerFormData = { + title: string; + body: string; + criticality: BannerCriticality; + dismissable: boolean; + requiresAcknowledgement: boolean; + displayStart: string; + displayEnd: string; + active: boolean; +}; + +const defaultFormData: BannerFormData = { + title: '', + body: '', + criticality: BannerCriticality.Info, + dismissable: true, + requiresAcknowledgement: false, + displayStart: new Date().toISOString().slice(0, 16), + displayEnd: '', + active: true, +}; + +interface BannerFormProps { + initialData?: Banner; + onSubmit: (data: BannerFormData) => Promise; + onCancel: () => void; + submitLabel: string; +} + +export const BannerForm = ({ + initialData, + onSubmit, + onCancel, + submitLabel, +}: BannerFormProps) => { + const [form, setForm] = useState( + initialData + ? { + title: initialData.title, + body: initialData.body, + criticality: initialData.criticality, + dismissable: initialData.dismissable, + requiresAcknowledgement: initialData.requiresAcknowledgement, + displayStart: new Date(initialData.displayStart) + .toISOString() + .slice(0, 16), + displayEnd: initialData.displayEnd + ? new Date(initialData.displayEnd).toISOString().slice(0, 16) + : '', + active: initialData.active, + } + : defaultFormData, + ); + const [showPreview, setShowPreview] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + await onSubmit(form); + } finally { + setSubmitting(false); + } + }, + [form, onSubmit], + ); + + const previewBanner: Banner = { + id: 'preview', + title: form.title || 'Preview Title', + body: form.body || 'Preview body text', + criticality: form.criticality, + dismissable: form.dismissable, + requiresAcknowledgement: form.requiresAcknowledgement, + displayStart: new Date(form.displayStart), + displayEnd: form.displayEnd ? new Date(form.displayEnd) : null, + active: form.active, + createdBy: '', + createdAt: new Date(), + updatedAt: new Date(), + }; + + return ( +
+
+ + setForm({ ...form, title: e.target.value })} + className='w-full rounded-md border border-border bg-background px-3 py-2 text-sm' + required + /> +
+ +
+ +