Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@fast-csv/format": "^5.0.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
Expand Down
28 changes: 28 additions & 0 deletions backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

245 changes: 245 additions & 0 deletions backend/src/common/interceptors/transaction-formatting.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

/**
* Asset configuration for formatting precision and symbols
*/
interface AssetConfig {
decimals: number;
symbol: string;
displaySymbol: string;
stellarExpertUrl: string;
}

/**
* Known Stellar assets with their precision and display configuration
*/
const ASSET_CONFIGS: Record<string, AssetConfig> = {
// USDC on Stellar (Circle)
CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA: {
decimals: 7,
symbol: 'USDC',
displaySymbol: '$',
stellarExpertUrl: 'https://stellar.expert/explorer/testnet',
},
// Default fallback for unknown assets
default: {
decimals: 7,
symbol: 'XLM',
displaySymbol: '◎',
stellarExpertUrl: 'https://stellar.expert/explorer/testnet',
},
};

/**
* Transaction Formatting Interceptor
*
* Enriches transaction responses with:
* - Formatted amounts (e.g., "$500.00" from "500000000")
* - Asset symbols and precision handling
* - Stellar Expert exploration links
* - Human-readable formatting for UI consumption
*/
@Injectable()
export class TransactionFormattingInterceptor implements NestInterceptor {
/**
* Intercept and enrich transaction responses
*/
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => this.formatTransactionData(data)));
}

/**
* Recursively format transaction data structures
*/
private formatTransactionData(data: any): any {
if (Array.isArray(data)) {
return data.map((item) => this.formatTransactionData(item));
}

if (data && typeof data === 'object') {
const formatted = { ...data };

// Format individual transaction objects
if (this.isTransactionObject(formatted)) {
return this.formatTransaction(formatted);
}

// Recursively format nested objects
for (const key in formatted) {
if (Object.hasOwn(formatted, key)) {
formatted[key] = this.formatTransactionData(formatted[key]);
}
}

return formatted;
}

return data;
}

/**
* Check if object represents a transaction
*/
private isTransactionObject(obj: any): boolean {
return (
obj &&
typeof obj === 'object' &&
(obj.amount !== undefined || obj.transactionHash !== undefined)
);
}

/**
* Format a single transaction object
*/
private formatTransaction(transaction: any): any {
const formatted = { ...transaction };

// Format amount if present
if (formatted.amount !== undefined) {
formatted.amountFormatted = this.formatAmount(
formatted.amount,
formatted.assetId || formatted.contractId,
);
}

// Add Stellar Expert links if transaction hash exists
if (formatted.transactionHash) {
formatted.explorerLinks = this.generateExplorerLinks(
formatted.transactionHash,
formatted.assetId || formatted.contractId,
);
}

// Add formatted date/time if createdAt exists
if (formatted.createdAt && !formatted.formattedDate) {
const date = new Date(formatted.createdAt);
formatted.formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
formatted.formattedTime = date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}

return formatted;
}

/**
* Format raw amount string to human-readable format
* @param amount Raw amount as string (e.g., "100000000")
* @param assetId Asset contract ID for precision lookup
* @returns Formatted amount object with multiple representations
*/
private formatAmount(amount: string | number, assetId?: string): any {
const assetConfig = this.getAssetConfig(assetId);
const rawAmount = typeof amount === 'string' ? amount : amount.toString();

try {
// Parse as decimal with proper precision
const numericAmount = parseFloat(rawAmount);
if (isNaN(numericAmount)) {
return {
raw: rawAmount,
formatted: 'Invalid Amount',
display: 'Invalid Amount',
symbol: assetConfig.symbol,
};
}

// Apply decimal precision
const displayAmount = numericAmount / Math.pow(10, assetConfig.decimals);

// Format with appropriate decimal places
const formattedAmount = this.formatNumberWithPrecision(displayAmount);

return {
raw: rawAmount,
numeric: displayAmount,
formatted: formattedAmount,
display: `${assetConfig.displaySymbol}${formattedAmount}`,
symbol: assetConfig.symbol,
decimals: assetConfig.decimals,
};
} catch (error) {
return {
raw: rawAmount,
formatted: 'Format Error',
display: 'Format Error',
symbol: assetConfig.symbol,
};
}
}

/**
* Format number with appropriate precision for display
*/
private formatNumberWithPrecision(amount: number): string {
// For amounts >= 1, show 2 decimal places
if (amount >= 1) {
return amount.toFixed(2);
}

// For amounts < 1, show up to 7 decimal places but remove trailing zeros
if (amount > 0) {
const formatted = amount.toFixed(7);
return formatted.replace(/\.?0+$/, '');
}

// For zero or negative amounts
return amount.toFixed(2);
}

/**
* Generate Stellar Expert exploration links
*/
private generateExplorerLinks(
transactionHash: string,
assetId?: string,
): any {
const assetConfig = this.getAssetConfig(assetId);
const baseUrl = assetConfig.stellarExpertUrl;

return {
transaction: `${baseUrl}/tx/${transactionHash}`,
search: `${baseUrl}/search?term=${transactionHash}`,
// Additional useful links
network: baseUrl,
};
}

/**
* Get asset configuration for formatting
*/
private getAssetConfig(assetId?: string): AssetConfig {
if (!assetId) {
return ASSET_CONFIGS.default;
}

return ASSET_CONFIGS[assetId] || ASSET_CONFIGS.default;
}

/**
* Add new asset configuration (useful for dynamic asset support)
*/
static addAssetConfig(assetId: string, config: AssetConfig): void {
ASSET_CONFIGS[assetId] = config;
}

/**
* Get all configured assets
*/
static getConfiguredAssets(): Record<string, AssetConfig> {
return { ...ASSET_CONFIGS };
}
}
6 changes: 5 additions & 1 deletion backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import {
} from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { Role } from '../../common/enums/role.enum';
import { ApproveKycDto, RejectKycDto } from '../user/dto/update-user.dto';

@Controller('admin')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
export class AdminController {
constructor(private readonly userService: UserService) {}

Expand Down
Loading
Loading