feat: Refactor bank account handling in billing and staff modules

- Introduced new bank account entities: BusinessBankAccount and StaffBankAccount.
- Updated bank account adapter to handle new entities.
- Removed legacy BankAccount entity and its adapter.
- Implemented use case for fetching bank accounts in billing repository.
- Updated BillingBloc and BillingState to include bank accounts.
- Refactored PaymentMethodCard to display bank account information.
- Adjusted actions widget layout for better UI consistency.
- Updated staff bank account repository and use cases to utilize new entity structure.
- Ensured all references to bank accounts in the codebase are updated to the new structure.
This commit is contained in:
Achintha Isuru
2026-02-17 12:05:24 -05:00
parent cccc8f35ed
commit 9e1af17328
25 changed files with 399 additions and 298 deletions

View File

@@ -3,6 +3,7 @@ import 'package:krow_core/core.dart';
import 'data/repositories_impl/billing_repository_impl.dart';
import 'domain/repositories/billing_repository.dart';
import 'domain/usecases/get_bank_accounts.dart';
import 'domain/usecases/get_current_bill_amount.dart';
import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart';
@@ -21,6 +22,7 @@ class BillingModule extends Module {
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
// Use Cases
i.addSingleton(GetBankAccountsUseCase.new);
i.addSingleton(GetCurrentBillAmountUseCase.new);
i.addSingleton(GetSavingsAmountUseCase.new);
i.addSingleton(GetPendingInvoicesUseCase.new);
@@ -30,6 +32,7 @@ class BillingModule extends Module {
// BLoCs
i.addSingleton<BillingBloc>(
() => BillingBloc(
getBankAccounts: i.get<GetBankAccountsUseCase>(),
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),

View File

@@ -16,6 +16,23 @@ class BillingRepositoryImpl implements BillingRepository {
final data_connect.DataConnectService _service;
/// Fetches bank accounts associated with the business.
@override
Future<List<BusinessBankAccount>> getBankAccounts() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<
data_connect.GetAccountsByOwnerIdData,
data_connect.GetAccountsByOwnerIdVariables> result =
await _service.connector
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data.accounts.map(_mapBankAccount).toList();
});
}
/// Fetches the current bill amount by aggregating open invoices.
@override
Future<double> getCurrentBillAmount() async {
@@ -182,6 +199,18 @@ class BillingRepositoryImpl implements BillingRepository {
);
}
BusinessBankAccount _mapBankAccount(
data_connect.GetAccountsByOwnerIdAccounts account,
) {
return BusinessBankAccountAdapter.fromPrimitives(
id: account.id,
bank: account.bank,
last4: account.last4,
isPrimary: account.isPrimary ?? false,
expiryTime: _service.toDateTime(account.expiryTime),
);
}
InvoiceStatus _mapInvoiceStatus(
data_connect.EnumValue<data_connect.InvoiceStatus> status,
) {

View File

@@ -7,6 +7,9 @@ import '../models/billing_period.dart';
/// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices();

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
/// Creates a [GetBankAccountsUseCase].
GetBankAccountsUseCase(this._repository);
final BillingRepository _repository;
@override
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.dart';
import '../../domain/usecases/get_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart';
@@ -16,12 +17,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases.
BillingBloc({
required GetBankAccountsUseCase getBankAccounts,
required GetCurrentBillAmountUseCase getCurrentBillAmount,
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getCurrentBillAmount = getCurrentBillAmount,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
@@ -31,6 +34,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged);
}
final GetBankAccountsUseCase _getBankAccounts;
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
@@ -52,12 +56,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices =
@@ -79,6 +86,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
),
);
},

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/billing_period.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
@@ -28,6 +29,7 @@ class BillingState extends Equatable {
this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.bankAccounts = const <BusinessBankAccount>[],
this.period = BillingPeriod.week,
this.errorMessage,
});
@@ -50,6 +52,9 @@ class BillingState extends Equatable {
/// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown;
/// Bank accounts associated with the business.
final List<BusinessBankAccount> bankAccounts;
/// Selected period for the breakdown.
final BillingPeriod period;
@@ -64,6 +69,7 @@ class BillingState extends Equatable {
List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown,
List<BusinessBankAccount>? bankAccounts,
BillingPeriod? period,
String? errorMessage,
}) {
@@ -74,6 +80,7 @@ class BillingState extends Equatable {
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
bankAccounts: bankAccounts ?? this.bankAccounts,
period: period ?? this.period,
errorMessage: errorMessage ?? this.errorMessage,
);
@@ -87,6 +94,7 @@ class BillingState extends Equatable {
pendingInvoices,
invoiceHistory,
spendingBreakdown,
bankAccounts,
period,
errorMessage,
];

View File

@@ -71,19 +71,20 @@ class _BillingViewState extends State<BillingView> {
@override
Widget build(BuildContext context) {
return BlocConsumer<BillingBloc, BillingState>(
listener: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.failure && state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, BillingState state) {
return Scaffold(
body: CustomScrollView(
return Scaffold(
body: BlocConsumer<BillingBloc, BillingState>(
listener: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, BillingState state) {
return CustomScrollView(
controller: _scrollController,
slivers: <Widget>[
SliverAppBar(
@@ -97,7 +98,7 @@ class _BillingViewState extends State<BillingView> {
leading: Center(
child: UiIconButton.secondary(
icon: UiIcons.arrowLeft,
onTap: () => Modular.to.toClientHome()
onTap: () => Modular.to.toClientHome(),
),
),
title: AnimatedSwitcher(
@@ -132,8 +133,9 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(height: UiConstants.space1),
Text(
'\$${state.currentBill.toStringAsFixed(2)}',
style: UiTypography.display1b
.copyWith(color: UiColors.white),
style: UiTypography.display1b.copyWith(
color: UiColors.white,
),
),
const SizedBox(height: UiConstants.space2),
Container(
@@ -171,16 +173,14 @@ class _BillingViewState extends State<BillingView> {
),
),
SliverList(
delegate: SliverChildListDelegate(
<Widget>[
_buildContent(context, state),
],
),
delegate: SliverChildListDelegate(<Widget>[
_buildContent(context, state),
]),
),
],
),
);
},
);
},
),
);
}
@@ -211,7 +211,9 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () => BlocProvider.of<BillingBloc>(context).add(const BillingLoadStarted()),
onPressed: () => BlocProvider.of<BillingBloc>(
context,
).add(const BillingLoadStarted()),
),
],
),
@@ -230,8 +232,10 @@ class _BillingViewState extends State<BillingView> {
],
const PaymentMethodCard(),
const SpendingBreakdownCard(),
if (state.invoiceHistory.isEmpty) _buildEmptyState(context)
else InvoiceHistorySection(invoices: state.invoiceHistory),
if (state.invoiceHistory.isEmpty)
_buildEmptyState(context)
else
InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space32),
],

View File

@@ -1,166 +1,133 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatefulWidget {
class PaymentMethodCard extends StatelessWidget {
/// Creates a [PaymentMethodCard].
const PaymentMethodCard({super.key});
@override
State<PaymentMethodCard> createState() => _PaymentMethodCardState();
}
class _PaymentMethodCardState extends State<PaymentMethodCard> {
late final Future<dc.GetAccountsByOwnerIdData?> _accountsFuture =
_loadAccounts();
Future<dc.GetAccountsByOwnerIdData?> _loadAccounts() async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return null;
}
final fdc.QueryResult<
dc.GetAccountsByOwnerIdData,
dc.GetAccountsByOwnerIdVariables
>
result = await dc.ExampleConnector.instance
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data;
}
@override
Widget build(BuildContext context) {
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
future: _accountsFuture,
builder:
(
BuildContext context,
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
) {
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
? accounts.first
: null;
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final List<BusinessBankAccount> accounts = state.bankAccounts;
final BusinessBankAccount? account =
accounts.isNotEmpty ? accounts.first : null;
if (account == null) {
return const SizedBox.shrink();
}
if (account == null) {
return const SizedBox.shrink();
}
final String bankLabel = account.bank.isNotEmpty == true
? account.bank
: '----';
final String last4 = account.last4.isNotEmpty == true
? account.last4
: '----';
final bool isPrimary = account.isPrimary ?? false;
final String expiryLabel = _formatExpiry(account.expiryTime);
final String bankLabel =
account.bankName.isNotEmpty == true ? account.bankName : '----';
final String last4 =
account.last4.isNotEmpty == true ? account.last4 : '----';
final bool isPrimary = account.isPrimary;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
child: Column(
],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
const SizedBox.shrink(),
],
),
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space6 + 4,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: UiConstants.radiusSm,
),
child: Center(
child: Text(
bankLabel,
style: UiTypography.footnote2b.white,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusSm,
),
child: Text(
t.client_billing.default_badge,
style: UiTypography.titleUppercase4b.textPrimary,
),
),
],
),
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
const SizedBox.shrink(),
],
),
);
},
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space6 + 4,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: UiConstants.radiusSm,
),
child: Center(
child: Text(
bankLabel,
style: UiTypography.footnote2b.white,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusSm,
),
child: Text(
t.client_billing.default_badge,
style: UiTypography.titleUppercase4b.textPrimary,
),
),
],
),
),
],
),
);
},
);
}
String _formatExpiry(fdc.Timestamp? expiryTime) {
String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) {
return 'N/A';
}
final DateTime date = expiryTime.toDateTime();
final String month = date.month.toString().padLeft(2, '0');
final String year = (date.year % 100).toString().padLeft(2, '0');
final String month = expiryTime.month.toString().padLeft(2, '0');
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
return '$month/$year';
}
}