diff --git a/apps/lib/features/points/data/apis/points_api.dart b/apps/lib/features/points/data/apis/points_api.dart index d53b085..621d915 100644 --- a/apps/lib/features/points/data/apis/points_api.dart +++ b/apps/lib/features/points/data/apis/points_api.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; +import '../models/ledger_item.dart'; import '../models/package_info.dart'; class PointsApi { @@ -11,4 +12,19 @@ class PointsApi { final response = await _dio.get('/api/v1/points/packages'); return PackagesResult.fromJson(response.data as Map); } + + Future getLedger({ + int limit = 20, + String? cursor, + }) async { + final query = {'limit': limit}; + if (cursor != null) { + query['cursor'] = cursor; + } + final response = await _dio.get( + '/api/v1/points/ledger', + queryParameters: query, + ); + return LedgerListResult.fromJson(response.data as Map); + } } diff --git a/apps/lib/features/points/data/models/ledger_item.dart b/apps/lib/features/points/data/models/ledger_item.dart new file mode 100644 index 0000000..0f50d20 --- /dev/null +++ b/apps/lib/features/points/data/models/ledger_item.dart @@ -0,0 +1,50 @@ +class LedgerItem { + const LedgerItem({ + required this.id, + required this.direction, + required this.amount, + required this.balanceAfter, + required this.changeType, + required this.createdAt, + }); + + final String id; + final int direction; + final int amount; + final int balanceAfter; + final String changeType; + final String createdAt; + + factory LedgerItem.fromJson(Map json) { + return LedgerItem( + id: json['id'] as String, + direction: json['direction'] as int, + amount: json['amount'] as int, + balanceAfter: json['balanceAfter'] as int, + changeType: json['changeType'] as String, + createdAt: json['createdAt'] as String, + ); + } +} + +class LedgerListResult { + const LedgerListResult({ + required this.items, + this.nextCursor, + required this.hasMore, + }); + + final List items; + final String? nextCursor; + final bool hasMore; + + factory LedgerListResult.fromJson(Map json) { + return LedgerListResult( + items: (json['items'] as List) + .map((e) => LedgerItem.fromJson(e as Map)) + .toList(), + nextCursor: json['nextCursor'] as String?, + hasMore: json['hasMore'] as bool, + ); + } +} diff --git a/apps/lib/features/points/presentation/screens/points_ledger_screen.dart b/apps/lib/features/points/presentation/screens/points_ledger_screen.dart new file mode 100644 index 0000000..6ffeed6 --- /dev/null +++ b/apps/lib/features/points/presentation/screens/points_ledger_screen.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/di/injection.dart'; +import '../../../../core/auth/session_store.dart'; +import '../../../../core/logging/logger.dart'; +import '../../../../data/network/api_client.dart'; +import '../../../../data/storage/local_kv_store.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/app_color_palette.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/utils/time_format.dart'; +import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../data/apis/points_api.dart'; +import '../../data/models/ledger_item.dart'; + +class PointsLedgerScreen extends StatefulWidget { + const PointsLedgerScreen({super.key}); + + @override + State createState() => _PointsLedgerScreenState(); +} + +class _PointsLedgerScreenState extends State { + final Logger _logger = getLogger('features.points.ledger_screen'); + late final ScrollController _scrollController; + late final PointsApi _api; + final List _items = []; + String? _nextCursor; + bool _hasMore = false; + bool _isLoading = false; + bool _isLoadingMore = false; + Object? _error; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_onScroll); + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + _api = PointsApi(apiClient.rawDio); + _loadInitial(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + _loadMore(); + } + } + + Future _loadInitial() async { + if (_isLoading) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await _api.getLedger(); + + if (mounted) { + setState(() { + _items.clear(); + _items.addAll(result.items); + _nextCursor = result.nextCursor; + _hasMore = result.hasMore; + _isLoading = false; + }); + } + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to load ledger', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + Future _loadMore() async { + if (_isLoadingMore || !_hasMore || _nextCursor == null) return; + + setState(() { + _isLoadingMore = true; + }); + + try { + final result = await _api.getLedger(cursor: _nextCursor); + + if (mounted) { + setState(() { + _items.addAll(result.items); + _nextCursor = result.nextCursor; + _hasMore = result.hasMore; + _isLoadingMore = false; + }); + } + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to load more ledger items', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + setState(() { + _isLoadingMore = false; + }); + } + } + } + + String _getDisplayText(AppLocalizations l10n, String changeType) { + return switch (changeType) { + 'register' => l10n.pointsLedgerTypeRegister, + 'purchase' => l10n.pointsLedgerTypePurchase, + 'consume' => l10n.pointsLedgerTypeConsume, + 'adjust' => l10n.pointsLedgerTypeAdjust, + 'refund' => l10n.pointsLedgerTypeRefund, + _ => changeType, + }; + } + + IconData _getIcon(String changeType) { + return switch (changeType) { + 'register' => Icons.card_giftcard_rounded, + 'purchase' => Icons.shopping_bag_rounded, + 'consume' => Icons.chat_bubble_outline_rounded, + 'adjust' => Icons.tune_rounded, + 'refund' => Icons.replay_rounded, + _ => Icons.swap_vert_rounded, + }; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.pointsLedgerTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: _buildBody(l10n, colors, palette), + ); + } + + Widget _buildBody( + AppLocalizations l10n, + ColorScheme colors, + AppColorPalette palette, + ) { + if (_isLoading) { + return const Center(child: AppLoadingIndicator()); + } + + if (_error != null && _items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: colors.error), + const SizedBox(height: AppSpacing.md), + Text( + l10n.errorRequestGeneric, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.lg), + FilledButton(onPressed: _loadInitial, child: Text(l10n.retry)), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 64, + color: colors.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.pointsLedgerEmpty, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.lg), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == _items.length) { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center(child: AppLoadingIndicator()), + ); + } + return const SizedBox.shrink(); + } + + final item = _items[index]; + final isIncome = item.direction == 1; + final amountText = isIncome ? '+${item.amount}' : '-${item.amount}'; + + return _LedgerItemCard( + icon: _getIcon(item.changeType), + displayText: _getDisplayText(l10n, item.changeType), + amountText: amountText, + isIncome: isIncome, + balanceAfter: item.balanceAfter, + balanceText: l10n.pointsLedgerBalance(item.balanceAfter), + dateTime: formatCompactLocalDateTime(item.createdAt), + colors: colors, + palette: palette, + ); + }, + ); + } +} + +class _LedgerItemCard extends StatelessWidget { + const _LedgerItemCard({ + required this.icon, + required this.displayText, + required this.amountText, + required this.isIncome, + required this.balanceAfter, + required this.balanceText, + required this.dateTime, + required this.colors, + required this.palette, + }); + + final IconData icon; + final String displayText; + final String amountText; + final bool isIncome; + final int balanceAfter; + final String balanceText; + final String dateTime; + final ColorScheme colors; + final AppColorPalette palette; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isIncome + ? palette.incomeGreenBg.withValues(alpha: 0.15) + : colors.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + icon, + size: 22, + color: isIncome ? palette.incomeGreenText : colors.error, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + dateTime, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amountText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: isIncome ? palette.incomeGreenText : colors.error, + ), + ), + const SizedBox(height: 2), + Text( + balanceText, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart b/apps/lib/features/settings/presentation/screens/account_data_screen.dart similarity index 87% rename from apps/lib/features/settings/presentation/screens/account_delete_screen.dart rename to apps/lib/features/settings/presentation/screens/account_data_screen.dart index 0f9df41..92e0d52 100644 --- a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart +++ b/apps/lib/features/settings/presentation/screens/account_data_screen.dart @@ -9,19 +9,20 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../points/presentation/screens/points_ledger_screen.dart'; import '../widgets/settings_section_widgets.dart'; -class AccountDeleteScreen extends StatefulWidget { - const AccountDeleteScreen({super.key, required this.onDeleteAccount}); +class AccountDataScreen extends StatefulWidget { + const AccountDataScreen({super.key, required this.onDeleteAccount}); final Future Function() onDeleteAccount; @override - State createState() => _AccountDeleteScreenState(); + State createState() => _AccountDataScreenState(); } -class _AccountDeleteScreenState extends State { - final Logger _logger = getLogger('features.settings.account_delete'); +class _AccountDataScreenState extends State { + final Logger _logger = getLogger('features.settings.account_data'); bool _isDeleting = false; @override @@ -42,6 +43,13 @@ class _AccountDeleteScreenState extends State { children: [ SettingsGroupCard( children: [ + SettingsMenuTile( + icon: Icons.receipt_long_rounded, + title: l10n.pointsLedgerTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: _openPointsLedger, + ), SettingsMenuTile( icon: Icons.delete_outline_rounded, title: l10n.settingsDeleteAccountTitle, @@ -58,6 +66,14 @@ class _AccountDeleteScreenState extends State { ); } + Future _openPointsLedger() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PointsLedgerScreen(), + ), + ); + } + Future _confirmDelete() async { final confirmed = await showDialog( context: context, @@ -194,8 +210,8 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { child: Text( l10n.settingsDeleteAccountDialogTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + fontWeight: FontWeight.w700, + ), ), ), ], @@ -204,9 +220,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { Text( l10n.settingsDeleteAccountWarningBody, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.45, - ), + color: colors.onSurfaceVariant, + height: 1.45, + ), ), const SizedBox(height: AppSpacing.sm), Container( @@ -220,10 +236,10 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { child: Text( l10n.settingsDeleteAccountReRegisterNotice, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onErrorContainer, - fontWeight: FontWeight.w700, - height: 1.35, - ), + color: colors.onErrorContainer, + fontWeight: FontWeight.w700, + height: 1.35, + ), ), ), const SizedBox(height: AppSpacing.md), @@ -232,9 +248,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { ? l10n.settingsDeleteAccountWaitAction(_secondsLeft) : l10n.settingsDeleteAccountDialogBody, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colors.error, - fontWeight: FontWeight.w600, - ), + color: colors.error, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: AppSpacing.lg), Row( diff --git a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart index f708926..7e53597 100644 --- a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart +++ b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart @@ -90,6 +90,32 @@ class _CoinCenterScreenState extends State { } } + Future _reloadPackages() async { + try { + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + + final api = PointsApi(apiClient.rawDio); + final result = await api.getPackages(); + + await _iapService?.loadStoreKitProducts(result.packages); + + if (mounted) { + setState(() { + _packages = result.packages; + }); + } + } catch (e, stackTrace) { + _logger.warning( + message: 'Failed to reload packages after purchase', + extra: {'error': e.toString()}, + ); + } + } + void _onPurchaseStateChanged() { final service = _iapService; if (service == null || !mounted) return; @@ -100,6 +126,7 @@ class _CoinCenterScreenState extends State { case PurchaseFlowState.success: Toast.show(context, l10n.paymentSuccess, type: ToastType.success); _refreshBalance(); + _reloadPackages(); service.resetState(); break; case PurchaseFlowState.failed: @@ -265,7 +292,7 @@ class _CoinCenterScreenState extends State { if (product != null) { return product.price; } - return pkg.priceDisplay; + return ''; } Future _handlePurchase(PackageInfo pkg) async { @@ -284,6 +311,9 @@ class _CoinCenterScreenState extends State { } String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) { + if (pkg.productCode == ProductCode.newUserPack) { + return l10n.settingsCoinPackNewUserBadge; + } if (pkg.productCode == ProductCode.popularPack) { return l10n.settingsCoinPackPopularBadge; } @@ -293,7 +323,7 @@ class _CoinCenterScreenState extends State { String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) { return switch (pkg.productCode) { ProductCode.newUserPack => l10n.settingsCoinPackStarter, - ProductCode.basicPack => l10n.settingsCoinPackBasic, + ProductCode.starterPack => l10n.settingsCoinPackBasic, ProductCode.popularPack => l10n.settingsCoinPackPopular, ProductCode.premiumPack => l10n.settingsCoinPackPremium, }; diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index e378666..bf9dd60 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -9,7 +9,7 @@ import '../../data/models/profile_settings.dart'; import '../../data/repositories/invite_repository.dart'; import '../models/legal_document_type.dart'; import '../utils/legal_document_assets.dart'; -import 'account_delete_screen.dart'; +import 'account_data_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'feedback_screen.dart'; @@ -18,6 +18,9 @@ import 'invite_screen.dart'; import 'legal_document_screen.dart'; import 'profile_edit_screen.dart'; +// 临时标志位:我的邀请逻辑施工完毕后删除此标志位,并恢复入口与子页面访问。 +final bool _showInviteEntry = false; + class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, @@ -48,7 +51,7 @@ class SettingsScreen extends StatefulWidget { final Future Function() onLogout; final Future Function() onDeleteAccount; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; final void Function(int newBalance) onBalanceChanged; @override @@ -118,13 +121,14 @@ class _SettingsScreenState extends State { background: colors.surfaceContainerHighest, onTap: _openGeneralSettings, ), - SettingsMenuTile( - icon: Icons.card_giftcard_rounded, - title: l10n.settingsInviteTitle, - tint: colors.primary, - background: colors.surfaceContainerHighest, - onTap: _openInvite, - ), + if (_showInviteEntry) + SettingsMenuTile( + icon: Icons.card_giftcard_rounded, + title: l10n.settingsInviteTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: _openInvite, + ), SettingsMenuTile( icon: Icons.feedback_outlined, title: l10n.settingsFeedbackTitle, @@ -142,7 +146,7 @@ class _SettingsScreenState extends State { tint: colors.primary, background: colors.surfaceContainerHighest, showDivider: false, - onTap: _openAccountDelete, + onTap: _openAccountData, ), ], ), @@ -161,7 +165,8 @@ class _SettingsScreenState extends State { title: l10n.privacyPolicy, tint: colors.secondary, background: colors.surfaceContainerHighest, - onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy), + onTap: () => + _openLegalDocument(LegalDocumentType.privacyPolicy), ), SettingsMenuTile( icon: Icons.description_outlined, @@ -169,7 +174,8 @@ class _SettingsScreenState extends State { tint: colors.secondary, background: colors.surfaceContainerHighest, showDivider: false, - onTap: () => _openLegalDocument(LegalDocumentType.termsOfService), + onTap: () => + _openLegalDocument(LegalDocumentType.termsOfService), ), ], ), @@ -222,6 +228,9 @@ class _SettingsScreenState extends State { } Future _openInvite() async { + if (!_showInviteEntry) { + return; + } await Navigator.of(context).push( MaterialPageRoute( builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository), @@ -261,11 +270,11 @@ class _SettingsScreenState extends State { ); } - Future _openAccountDelete() async { + Future _openAccountData() async { final deleted = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => - AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount), + AccountDataScreen(onDeleteAccount: widget.onDeleteAccount), ), ); if (deleted != true) { diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index 106d30b..0a5932b 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -445,6 +445,74 @@ class CoinPackageCard extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.xl), ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.settingsCoinAmount(amount), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: palette.historyGoldBg, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + badge!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: palette.historyGoldText, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + if (!isAvailable && unavailableMessage != null) + Text( + unavailableMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.error, + ), + ) + else + Row( + children: [ + Text( + price, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(color: colors.primary), + ), + const Spacer(), + FilledButton( + onPressed: isPurchasing || !isAvailable ? null : onPurchase, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: Column( diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index eef55bc..2264f79 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from decimal import Decimal from uuid import UUID @@ -209,3 +210,19 @@ class PointsRepository: stmt = select(Profile.settings).where(Profile.id == user_id).limit(1) row = (await self._session.execute(stmt)).scalar_one_or_none() return row + + async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, + ) -> tuple[list[PointsLedger], bool]: + stmt = select(PointsLedger).where(PointsLedger.user_id == user_id) + if cursor is not None: + stmt = stmt.where(PointsLedger.created_at < cursor) + stmt = stmt.order_by(PointsLedger.created_at.desc()).limit(limit + 1) + rows = list((await self._session.execute(stmt)).scalars().all()) + has_more = len(rows) > limit + items = rows[:limit] + return (items, has_more) diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 4591112..6925ea7 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -1,18 +1,42 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload from v1.points.dependencies import get_points_service -from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse +from v1.points.schemas import ( + PackagesResponse, + PackageInfo, + PointsBalanceResponse, + LedgerListResponse, + LedgerItem, +) from v1.points.service import PointsService from v1.users.dependencies import get_current_user router = APIRouter(prefix="/points", tags=["points"]) +def _parse_cursor(cursor: str | None) -> datetime | None: + if cursor is None: + return None + try: + return datetime.fromisoformat(cursor.replace("Z", "+00:00")) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="POINTS_INVALID_CURSOR", + detail="Points ledger cursor must be an ISO 8601 datetime", + params={"cursor": cursor}, + ), + ) from exc + + @router.get("/balance", response_model=PointsBalanceResponse) async def get_points_balance( service: Annotated[PointsService, Depends(get_points_service)], @@ -55,3 +79,32 @@ async def get_available_packages( for pkg in result.packages ], ) + + +@router.get("/ledger", response_model=LedgerListResponse) +async def get_points_ledger( + service: Annotated[PointsService, Depends(get_points_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: str | None = None, +) -> LedgerListResponse: + items, next_cursor, has_more = await service.get_ledger_list( + user_id=current_user.id, + limit=limit, + cursor=_parse_cursor(cursor), + ) + return LedgerListResponse( + items=[ + LedgerItem( + id=item.id, + direction=item.direction, + amount=item.amount, + balanceAfter=item.balance_after, + changeType=item.change_type, + createdAt=item.created_at, + ) + for item in items + ], + nextCursor=next_cursor, + hasMore=has_more, + ) diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 4422166..3f774ae 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -23,7 +23,6 @@ class PackageInfo(BaseModel): alias="appStoreProductId", min_length=1, max_length=256 ) type: Literal["starter", "regular"] - price: float = Field(ge=0) credits: int = Field(ge=1) is_starter: bool = Field(alias="isStarter") starter_eligible: bool = Field(alias="starterEligible") @@ -33,6 +32,23 @@ class PackageInfo(BaseModel): class PackagesResponse(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) - region: str = Field(min_length=1, max_length=8) - currency: str = Field(min_length=1, max_length=8) packages: list[PackageInfo] + + +class LedgerItem(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + id: str + direction: int + amount: int = Field(ge=1) + balance_after: int = Field(alias="balanceAfter", ge=0) + change_type: str = Field(alias="changeType") + created_at: str = Field(alias="createdAt") + + +class LedgerListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + items: list[LedgerItem] + next_cursor: str | None = Field(alias="nextCursor", default=None) + has_more: bool = Field(alias="hasMore") diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index efec1e2..820dee1 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -1,16 +1,13 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from decimal import Decimal import hashlib import hmac from typing import TYPE_CHECKING, Literal from uuid import UUID, uuid4 -from core.config.packages import ( - PackageType, - get_packages_config_for_region, -) from core.config.settings import config from core.http.errors import ApiProblemError, problem_payload from schemas.domain.points import ( @@ -22,9 +19,9 @@ from schemas.domain.points import ( ) from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand -from schemas.shared.user import parse_profile_settings from v1.payments.service import _load_product_mappings from v1.points.repository import PointsRepository +from v1.points.schemas import LedgerItem if TYPE_CHECKING: pass @@ -69,8 +66,7 @@ class RegisterBonusResult: class PackageInfoResult: product_code: str app_store_product_id: str - type: PackageType - price: float + type: Literal["starter", "regular"] credits: int sort_order: int is_starter: bool @@ -79,8 +75,6 @@ class PackageInfoResult: @dataclass(frozen=True) class PackagesResult: - region: str - currency: str packages: list[PackageInfoResult] @@ -449,11 +443,6 @@ class PointsService: user_id: UUID, user_email: str, ) -> PackagesResult: - settings_raw = await self._repository.get_profile_settings(user_id=user_id) - settings = parse_profile_settings(settings_raw) - country = settings.preferences.country - - pkg_config = get_packages_config_for_region(country) normalized_email = self._normalize_email(user_email) has_starter = False @@ -466,32 +455,59 @@ class PointsService: product_mappings = _load_product_mappings() packages: list[PackageInfoResult] = [] - for pkg in pkg_config.packages: - if not pkg.enabled: + for product_code, mapping in product_mappings.items(): + if not mapping.enabled: continue - if pkg.type == PackageType.STARTER and has_starter: + pkg_type: Literal["starter", "regular"] = ( + "starter" if mapping.type == "starter" else "regular" + ) + if pkg_type == "starter" and has_starter: continue - mapping = product_mappings.get(pkg.product_code) - app_store_product_id = mapping.app_store_product_id if mapping else "" - packages.append( PackageInfoResult( - product_code=pkg.product_code, - app_store_product_id=app_store_product_id, - type=pkg.type, - price=pkg.price, - credits=pkg.credits, - sort_order=pkg.sort_order, - is_starter=pkg.type == PackageType.STARTER, - starter_eligible=( - pkg.type == PackageType.STARTER and not has_starter - ), + product_code=product_code, + app_store_product_id=mapping.app_store_product_id, + type=pkg_type, + credits=mapping.credits, + sort_order=mapping.sort_order, + is_starter=pkg_type == "starter", + starter_eligible=(pkg_type == "starter" and not has_starter), ) ) return PackagesResult( - region=pkg_config.region, - currency=pkg_config.currency, packages=sorted(packages, key=lambda p: p.sort_order), ) + + async def get_ledger_list( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: datetime | None = None, + ) -> tuple[list[LedgerItem], str | None, bool]: + rows, has_more = await self._repository.list_ledger( + user_id=user_id, + limit=limit, + cursor=cursor, + ) + + items: list[LedgerItem] = [] + for row in rows: + items.append( + LedgerItem( + id=str(row.id), + direction=row.direction, + amount=row.amount, + balance_after=row.balance_after, + change_type=row.change_type, + created_at=row.created_at.isoformat(), + ) + ) + + next_cursor: str | None = None + if has_more and items: + next_cursor = items[-1].created_at + + return (items, next_cursor, has_more) diff --git a/backend/tests/unit/test_points_service_audit.py b/backend/tests/unit/test_points_service_audit.py index b357beb..e4e4c60 100644 --- a/backend/tests/unit/test_points_service_audit.py +++ b/backend/tests/unit/test_points_service_audit.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timezone from decimal import Decimal from uuid import UUID, uuid4 @@ -22,12 +23,23 @@ class _FakeAccount: version: int = 0 +@dataclass +class _FakeLedgerRow: + id: UUID + direction: int + amount: int + balance_after: int + change_type: str + created_at: datetime + + class _FakePointsRepository: def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None: self.account = _FakeAccount() self.usage_snapshot = usage_snapshot self.appended_ledger: list[ApplyPointsChangeCommand] = [] self.appended_audit: list[AppendAuditLedgerCommand] = [] + self.ledger_rows: list[_FakeLedgerRow] = [] self.claimed: bool = False self.claim: RegisterBonusClaims | None = None @@ -88,6 +100,16 @@ class _FakePointsRepository: del email_hash return self.claim + async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, + ) -> tuple[list[_FakeLedgerRow], bool]: + del user_id, cursor + return (self.ledger_rows[:limit], len(self.ledger_rows) > limit) + @pytest.mark.asyncio async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None: @@ -225,3 +247,68 @@ async def test_grant_register_bonus_if_eligible_restores_balance_snapshot() -> N assert repository.claimed is False assert len(repository.appended_ledger) == 0 assert len(repository.appended_audit) == 0 + + +@pytest.mark.asyncio +async def test_get_ledger_list_returns_items_and_next_cursor() -> None: + created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc) + repository = _FakePointsRepository(usage_snapshot=None) + repository.ledger_rows = [ + _FakeLedgerRow( + id=uuid4(), + direction=1, + amount=60, + balance_after=160, + change_type="purchase", + created_at=created_at, + ), + ] + service = PointsService(repository=repository) # type: ignore[arg-type] + + items, next_cursor, has_more = await service.get_ledger_list( + user_id=uuid4(), + limit=1, + ) + + assert has_more is False + assert next_cursor is None + assert len(items) == 1 + assert items[0].amount == 60 + assert items[0].balance_after == 160 + assert items[0].change_type == "purchase" + assert items[0].created_at == created_at.isoformat() + + +@pytest.mark.asyncio +async def test_get_ledger_list_sets_next_cursor_when_more_rows_exist() -> None: + first_created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc) + second_created_at = datetime(2026, 4, 27, 8, 30, tzinfo=timezone.utc) + repository = _FakePointsRepository(usage_snapshot=None) + repository.ledger_rows = [ + _FakeLedgerRow( + id=uuid4(), + direction=1, + amount=60, + balance_after=160, + change_type="purchase", + created_at=first_created_at, + ), + _FakeLedgerRow( + id=uuid4(), + direction=-1, + amount=20, + balance_after=140, + change_type="consume", + created_at=second_created_at, + ), + ] + service = PointsService(repository=repository) # type: ignore[arg-type] + + items, next_cursor, has_more = await service.get_ledger_list( + user_id=uuid4(), + limit=1, + ) + + assert has_more is True + assert len(items) == 1 + assert next_cursor == first_created_at.isoformat()