diff --git a/backend/lcfs/tests/credit_ledger/test_credit_ledger_repo.py b/backend/lcfs/tests/credit_ledger/test_credit_ledger_repo.py index 7cdc637f4..0f3acba75 100644 --- a/backend/lcfs/tests/credit_ledger/test_credit_ledger_repo.py +++ b/backend/lcfs/tests/credit_ledger/test_credit_ledger_repo.py @@ -26,7 +26,7 @@ async def test_get_rows_default_sort( ): fake_row = MagicMock() execute_result = MagicMock() - execute_result.scalars.return_value.all.return_value = [fake_row] + execute_result.all.return_value = [fake_row] mock_session.execute.return_value = execute_result mock_session.scalar.return_value = 1 @@ -51,7 +51,7 @@ async def test_get_rows_with_sort_and_paging( ): fake_rows = [MagicMock(), MagicMock()] execute_result = MagicMock() - execute_result.scalars.return_value.all.return_value = fake_rows + execute_result.all.return_value = fake_rows mock_session.execute.return_value = execute_result mock_session.scalar.return_value = 2 @@ -73,7 +73,9 @@ async def test_get_rows_with_sort_and_paging( @pytest.mark.anyio -async def test_get_distinct_years(repo: CreditLedgerRepository, mock_session: MagicMock): +async def test_get_distinct_years( + repo: CreditLedgerRepository, mock_session: MagicMock +): """Test getting distinct years for an organization.""" fake_years = ["2024", "2023", "2022"] execute_result = MagicMock() @@ -89,11 +91,13 @@ async def test_get_distinct_years(repo: CreditLedgerRepository, mock_session: Ma @pytest.mark.anyio -async def test_get_distinct_years_filters_nulls(repo: CreditLedgerRepository, mock_session: MagicMock): +async def test_get_distinct_years_filters_nulls( + repo: CreditLedgerRepository, mock_session: MagicMock +): """Test that get_distinct_years filters out null years.""" fake_years_with_nulls = ["2024", None, "2023", "", "2022"] expected_years = ["2024", "2023", "2022"] - + execute_result = MagicMock() execute_result.scalars.return_value.all.return_value = fake_years_with_nulls diff --git a/backend/lcfs/tests/credit_ledger/test_credit_ledger_services.py b/backend/lcfs/tests/credit_ledger/test_credit_ledger_services.py index 0230e4768..0c4cde02f 100644 --- a/backend/lcfs/tests/credit_ledger/test_credit_ledger_services.py +++ b/backend/lcfs/tests/credit_ledger/test_credit_ledger_services.py @@ -1,4 +1,5 @@ from math import ceil +from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -29,17 +30,15 @@ async def test_get_ledger_paginated_success(credit_ledger_service, mock_repo): page=2, size=5, filters=[], sort_orders=[] ) - mock_rows = [ - SimpleNamespace( - transaction_type="Credit", - compliance_period="2023", - organization_id=1, - compliance_units=10, - available_balance=10, - update_date="2024-01-01", - ) - for _ in range(3) - ] + ledger_view = SimpleNamespace( + transaction_type="ComplianceReport", + compliance_period="2023", + organization_id=1, + compliance_units=10, + available_balance=10, + update_date="2024-01-01", + ) + mock_rows = [(ledger_view, 2)] mock_repo.get_rows_paginated.return_value = (mock_rows, 12) data = await credit_ledger_service.get_ledger_paginated( @@ -48,8 +47,9 @@ async def test_get_ledger_paginated_success(credit_ledger_service, mock_repo): assert data.pagination.total == 12 assert data.pagination.total_pages == ceil(12 / 5) - assert len(data.ledger) == 3 + assert len(data.ledger) == 1 assert isinstance(data.ledger[0], CreditLedgerTxnSchema) + assert data.ledger[0].description == "Supplemental 2" @pytest.mark.anyio @@ -57,8 +57,18 @@ async def test_export_transactions_generates_stream(credit_ledger_service, mock_ with patch( "lcfs.web.api.credit_ledger.services.SpreadsheetBuilder.build_spreadsheet", return_value=b"dummy-bytes", - ): - mock_repo.get_rows_paginated.return_value = ([], 0) + ), patch( + "lcfs.web.api.credit_ledger.services.SpreadsheetBuilder.add_sheet" + ) as mock_add_sheet: + ledger_view = SimpleNamespace( + transaction_type="ComplianceReport", + compliance_period="2023", + organization_id=1, + compliance_units=10, + available_balance=10, + update_date=datetime(2024, 1, 1), + ) + mock_repo.get_rows_paginated.return_value = ([(ledger_view, 1)], 1) resp = await credit_ledger_service.export_transactions( organization_id=1, compliance_year=None, export_format="csv" @@ -67,6 +77,9 @@ async def test_export_transactions_generates_stream(credit_ledger_service, mock_ assert isinstance(resp, StreamingResponse) assert resp.media_type == "text/csv" assert resp.headers["Content-Disposition"].startswith("attachment;") + assert mock_add_sheet.called + _, kwargs = mock_add_sheet.call_args + assert kwargs["rows"][0][3] == "Compliance Report – Supplemental 1" @pytest.mark.anyio @@ -74,21 +87,29 @@ async def test_get_organization_years_success(credit_ledger_service, mock_repo): """Test getting organization years returns years from repo.""" expected_years = ["2024", "2023", "2022"] mock_repo.get_distinct_years.return_value = expected_years - + organization_id = 123 - years = await credit_ledger_service.get_organization_years(organization_id=organization_id) - + years = await credit_ledger_service.get_organization_years( + organization_id=organization_id + ) + assert years == expected_years - mock_repo.get_distinct_years.assert_called_once_with(organization_id=organization_id) + mock_repo.get_distinct_years.assert_called_once_with( + organization_id=organization_id + ) @pytest.mark.anyio async def test_get_organization_years_empty_list(credit_ledger_service, mock_repo): """Test getting organization years returns empty list when no data.""" mock_repo.get_distinct_years.return_value = [] - + organization_id = 456 - years = await credit_ledger_service.get_organization_years(organization_id=organization_id) - + years = await credit_ledger_service.get_organization_years( + organization_id=organization_id + ) + assert years == [] - mock_repo.get_distinct_years.assert_called_once_with(organization_id=organization_id) + mock_repo.get_distinct_years.assert_called_once_with( + organization_id=organization_id + ) diff --git a/backend/lcfs/web/api/credit_ledger/repo.py b/backend/lcfs/web/api/credit_ledger/repo.py index c60ea34d3..15dcf774b 100644 --- a/backend/lcfs/web/api/credit_ledger/repo.py +++ b/backend/lcfs/web/api/credit_ledger/repo.py @@ -2,12 +2,13 @@ from typing import Optional, List from fastapi import Depends -from sqlalchemy import func, select, and_, desc, asc, distinct +from sqlalchemy import func, select, and_, desc, distinct from sqlalchemy.ext.asyncio import AsyncSession from lcfs.db.dependencies import get_async_db_session from lcfs.web.core.decorators import repo_handler from lcfs.db.models.transaction.CreditLedgerView import CreditLedgerView +from lcfs.db.models.compliance.ComplianceReport import ComplianceReport log = structlog.get_logger(__name__) @@ -28,24 +29,38 @@ async def get_rows_paginated( limit: Optional[int], conditions: List[any], sort_orders: List[any], - ) -> tuple[List[CreditLedgerView], int]: - # Base query - stmt = select(CreditLedgerView).where(and_(*conditions)) + ) -> tuple[List[tuple], int]: + # Base query - join with compliance_report to get version for ComplianceReport transactions + stmt = ( + select( + CreditLedgerView, + ComplianceReport.version.label("compliance_report_version"), + ) + .outerjoin( + ComplianceReport, + and_( + CreditLedgerView.transaction_id + == ComplianceReport.compliance_report_id, + CreditLedgerView.transaction_type == "ComplianceReport", + ), + ) + .where(and_(*conditions)) + ) - # Sort and order - for order in sort_orders: - direction = asc if order.direction == "asc" else desc - stmt = stmt.order_by(direction(getattr(CreditLedgerView, order.field))) - if not sort_orders: - stmt = stmt.order_by(CreditLedgerView.update_date.desc()) + # Always sort by update_date DESC - sorting is not allowed on credit ledger + stmt = stmt.order_by(CreditLedgerView.update_date.desc()) # Count before pagination - total = await self.db.scalar(select(func.count()).select_from(stmt.subquery())) + count_stmt = select(func.count()).select_from( + select(CreditLedgerView).where(and_(*conditions)).subquery() + ) + total = await self.db.scalar(count_stmt) # Pagination stmt = stmt.offset(offset).limit(limit) - rows = (await self.db.execute(stmt)).scalars().all() + result = await self.db.execute(stmt) + rows = result.all() return rows, total or 0 @repo_handler @@ -64,7 +79,7 @@ async def get_distinct_years( .where(CreditLedgerView.compliance_period.isnot(None)) .order_by(desc(CreditLedgerView.compliance_period)) ) - + result = await self.db.execute(stmt) years = result.scalars().all() return [str(year) for year in years if year] diff --git a/backend/lcfs/web/api/credit_ledger/schema.py b/backend/lcfs/web/api/credit_ledger/schema.py index 8a10d95c4..892cc4859 100644 --- a/backend/lcfs/web/api/credit_ledger/schema.py +++ b/backend/lcfs/web/api/credit_ledger/schema.py @@ -7,6 +7,7 @@ class CreditLedgerTxnSchema(BaseSchema): transaction_type: str + description: Optional[str] = None compliance_period: str organization_id: int compliance_units: int @@ -14,8 +15,8 @@ class CreditLedgerTxnSchema(BaseSchema): update_date: datetime model_config = ConfigDict(from_attributes=True) - - @field_validator('available_balance') + + @field_validator("available_balance") @classmethod def validate_available_balance(cls, v: Optional[int]) -> int: """Ensure available balance is never negative - display 0 instead""" diff --git a/backend/lcfs/web/api/credit_ledger/services.py b/backend/lcfs/web/api/credit_ledger/services.py index 12311db15..4dd4b266d 100644 --- a/backend/lcfs/web/api/credit_ledger/services.py +++ b/backend/lcfs/web/api/credit_ledger/services.py @@ -29,7 +29,9 @@ class CreditLedgerService: def __init__(self, repo: CreditLedgerRepository = Depends()) -> None: self.repo = repo - def _apply_filters(self, pagination: PaginationRequestSchema, conditions: List[any]) -> None: + def _apply_filters( + self, pagination: PaginationRequestSchema, conditions: List[any] + ) -> None: for f in pagination.filters: field = get_field_for_filter(CreditLedgerView, f.field) filter_val = f.filter @@ -62,8 +64,26 @@ async def get_ledger_paginated( sort_orders=pagination.sort_orders, ) + # Transform rows with compliance report version (e.g., "Original", "Supplemental 1") + ledger_items = [] + for row in rows: + ledger_view, version = row + # Create schema from the ledger view + item = CreditLedgerTxnSchema.model_validate(ledger_view) + + # Add formatted description for compliance reports + if ( + ledger_view.transaction_type == "ComplianceReport" + and version is not None + ): + item.description = ( + "Original" if version == 0 else f"Supplemental {version}" + ) + + ledger_items.append(item) + return CreditLedgerListSchema( - ledger=[CreditLedgerTxnSchema.model_validate(r) for r in rows], + ledger=ledger_items, pagination=PaginationResponseSchema( total=total, page=pagination.page, @@ -113,16 +133,33 @@ async def export_transactions( sort_orders=sort_orders, ) - sheet_rows = [ - [ - int(r.compliance_period), - int(r.available_balance or 0), - int(r.compliance_units or 0), - r.transaction_type, - r.update_date.strftime("%Y-%m-%d"), - ] - for r in rows - ] + sheet_rows = [] + for row in rows: + ledger_view, version = row + + # Format transaction type with version for compliance reports + transaction_type = ledger_view.transaction_type + if transaction_type == "ComplianceReport" and version is not None: + # Format as "Original", "Supplemental 1", etc. + description = "Original" if version == 0 else f"Supplemental {version}" + transaction_type = f"Compliance Report – {description}" + elif transaction_type == "StandaloneTransaction": + transaction_type = "Legacy Transaction" + else: + # Add spaces to camelCase + transaction_type = "".join( + [" " + c if c.isupper() else c for c in transaction_type] + ).strip() + + sheet_rows.append( + [ + int(ledger_view.compliance_period), + int(ledger_view.available_balance or 0), + int(ledger_view.compliance_units or 0), + transaction_type, + ledger_view.update_date.strftime("%Y-%m-%d"), + ] + ) builder = SpreadsheetBuilder(file_format=export_format) builder.add_sheet( diff --git a/frontend/src/views/Organizations/OrganizationView/CreditLedger.jsx b/frontend/src/views/Organizations/OrganizationView/CreditLedger.jsx index 7e7ba6b54..6a54b5ca9 100644 --- a/frontend/src/views/Organizations/OrganizationView/CreditLedger.jsx +++ b/frontend/src/views/Organizations/OrganizationView/CreditLedger.jsx @@ -17,7 +17,11 @@ import { } from '@/hooks/useOrganization' import { useCurrentUser } from '@/hooks/useCurrentUser' import { useTranslation } from 'react-i18next' -import { timezoneFormatter, numberFormatter, spacesFormatter } from '@/utils/formatters' +import { + timezoneFormatter, + numberFormatter, + spacesFormatter +} from '@/utils/formatters' export const CreditLedger = ({ organizationId }) => { const { t } = useTranslation(['org', 'common']) @@ -118,6 +122,7 @@ export const CreditLedger = ({ organizationId }) => { availableBalance: r.availableBalance, complianceUnits: r.complianceUnits, transactionType: r.transactionType, + description: r.description, updateDate: r.updateDate })) @@ -152,38 +157,57 @@ export const CreditLedger = ({ organizationId }) => { { field: 'compliancePeriod', headerName: t('org:ledger.complianceYear'), - minWidth: 130 + minWidth: 130, + sortable: false }, { field: 'availableBalance', headerName: t('org:ledger.availableBalance'), valueFormatter: numberFormatter, - minWidth: 170 + minWidth: 170, + sortable: false }, { field: 'complianceUnits', headerName: t('org:ledger.complianceUnits'), valueFormatter: numberFormatter, - minWidth: 150 + minWidth: 150, + sortable: false }, { field: 'transactionType', headerName: t('org:ledger.transactionType'), - valueFormatter: (params) => - params.value === 'StandaloneTransaction' - ? 'Legacy Transaction' - : spacesFormatter(params), - minWidth: 160 + valueFormatter: (params) => { + const transactionType = params.data.transactionType + const description = params.data.description + + // Map StandaloneTransaction to Legacy Transaction for display + const displayType = + transactionType === 'StandaloneTransaction' + ? 'Legacy Transaction' + : spacesFormatter({ value: transactionType }) + + // Append description for Compliance Reports (e.g., "Compliance Report - Original") + if (transactionType === 'ComplianceReport' && description) { + return `${displayType} – ${description}` + } + + return displayType + }, + minWidth: 300, + sortable: false }, { field: 'updateDate', headerName: t('org:ledger.transactionDate'), valueFormatter: timezoneFormatter, - minWidth: 180 + minWidth: 180, + sortable: false, + sort: 'desc' } ] const getRowId = (p) => - `${p.data.updateDate}-${p.data.transactionType}-${p.data.complianceUnits}` + `${p.data.updateDate}-${p.data.transactionType}-${p.data.complianceUnits}-${p.data.description || ''}` return ( @@ -266,7 +290,7 @@ export const CreditLedger = ({ organizationId }) => { onPaginationChange={onPaginationChange} defaultColDef={{ filter: false, - sortable: true, + sortable: false, floatingFilter: false }} /> diff --git a/frontend/src/views/Organizations/OrganizationView/__tests__/CreditLedger.test.jsx b/frontend/src/views/Organizations/OrganizationView/__tests__/CreditLedger.test.jsx index 34f54c86c..e59ecf94f 100644 --- a/frontend/src/views/Organizations/OrganizationView/__tests__/CreditLedger.test.jsx +++ b/frontend/src/views/Organizations/OrganizationView/__tests__/CreditLedger.test.jsx @@ -19,7 +19,10 @@ vi.mock('react-i18next', () => ({ // Mock formatters vi.mock('@/utils/formatters', () => ({ timezoneFormatter: vi.fn((value) => value), - numberFormatter: vi.fn((value) => value) + numberFormatter: vi.fn((value) => value), + spacesFormatter: vi.fn(({ value }) => + value ? value.replace(/([a-z])([A-Z])/g, '$1 $2') : value + ) })) // Mock components @@ -86,6 +89,8 @@ vi.mock('@/components/DownloadButton', () => ({ } })) +let lastGridProps = null + vi.mock('@/components/BCDataGrid/BCGridViewer', () => { const React = require('react') return { @@ -106,6 +111,18 @@ vi.mock('@/components/BCDataGrid/BCGridViewer', () => { }, ref ) => { + lastGridProps = { + queryData, + onPaginationChange, + getRowId, + gridKey, + dataKey, + columnDefs, + suppressPagination, + paginationOptions, + defaultColDef, + autoSizeStrategy + } const domProps = {} // Only pass through standard DOM attributes Object.keys(props).forEach((key) => { @@ -201,6 +218,7 @@ const renderComponent = (props = {}) => { describe('CreditLedger Component Tests', () => { beforeEach(() => { vi.clearAllMocks() + lastGridProps = null // Default mock implementations mockUseCurrentUser.mockReturnValue({ @@ -342,6 +360,39 @@ describe('CreditLedger Component Tests', () => { }) describe('Data Processing', () => { + it('formats compliance report transaction type with description', () => { + const ledgerData = [ + { + compliancePeriod: '2023', + availableBalance: '1000', + complianceUnits: '500', + transactionType: 'ComplianceReport', + description: 'Supplemental 2', + updateDate: '2023-01-01' + } + ] + + mockUseCreditLedger.mockReturnValue({ + data: { + ledger: ledgerData, + pagination: { page: 1, size: 10, total: 1, totalPages: 1 } + }, + isLoading: false + }) + + renderComponent() + + const transactionTypeCol = lastGridProps.columnDefs.find( + (col) => col.field === 'transactionType' + ) + + const formatted = transactionTypeCol.valueFormatter({ + data: ledgerData[0] + }) + + expect(formatted).toBe('Compliance Report – Supplemental 2') + }) + it('transforms ledger data correctly', () => { const ledgerData = [ {