feat(gcash): complete Phase 4 dual-float ledger, customizable fee scheduler, rate card PDF, and receipt OCR

This commit is contained in:
ssbg04 committed 2026-10-20 20:01:46 +08:00
1 parent b4ae4e0185
commit ca07d6a308
7 files changed
+1848 -215

No files matched your search

+230 -2
View File
@@ -26,6 +26,26 @@ part 'app_database.g.dart';
GcashFeeConfig,
SyncQueue,
])
class DualFloatBalance {
final double gcashFloat;
final double cashDrawer;
final double totalFeesEarned;
final int totalTransactions;
final int cashInCount;
final int cashOutCount;
final int storePaymentCount;
const DualFloatBalance({
required this.gcashFloat,
required this.cashDrawer,
required this.totalFeesEarned,
required this.totalTransactions,
required this.cashInCount,
required this.cashOutCount,
required this.storePaymentCount,
});
}
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@@ -472,7 +492,7 @@ class AppDatabase extends _$AppDatabase {
}
// --------------------------------------------------------------------------
// GCASH LEDGER OPERATIONS
// GCASH LEDGER OPERATIONS & DUAL-FLOAT ENGINE
// --------------------------------------------------------------------------
/// Watch GCash transaction history
@@ -480,8 +500,18 @@ class AppDatabase extends _$AppDatabase {
return (select(gcashLedger)..orderBy([(g) => OrderingTerm.desc(g.timestamp)])).watch();
}
/// Check if a GCash reference number has already been logged (anti-fraud/scam check)
Future<bool> isReferenceNumberUsed(String refNo) async {
final clean = refNo.replaceAll(RegExp(r'\s+'), '').trim();
if (clean.isEmpty) return false;
final all = await select(gcashLedger).get();
return all.any((t) =>
t.referenceNumber != null &&
t.referenceNumber!.replaceAll(RegExp(r'\s+'), '').trim().toLowerCase() == clean.toLowerCase());
}
/// Record GCash cash-in / cash-out with convenience fee
Future<void> recordGcash({
Future<String> recordGcash({
required String transactionType,
required double amount,
required double feeCharged,
@@ -523,6 +553,204 @@ class AppDatabase extends _$AppDatabase {
},
);
});
return gcashId;
}
/// Alias for recordGcash to support flexible naming
Future<String> recordGcashEntry({
required String transactionType,
required double amount,
required double feeCharged,
String? referenceNumber,
String? customerName,
String? customerPhone,
String? notes,
}) => recordGcash(
transactionType: transactionType,
amount: amount,
feeCharged: feeCharged,
referenceNumber: referenceNumber,
customerName: customerName,
customerPhone: customerPhone,
notes: notes,
);
/// Watch customizable fee schedule brackets
Stream<List<GcashFeeBracket>> watchFeeBrackets() {
return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).watch();
}
/// Get current fee brackets
Future<List<GcashFeeBracket>> getFeeBrackets() {
return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).get();
}
/// Seed default Philippine sari-sari store fee schedule if empty
Future<void> seedDefaultFeeBrackets() async {
final existing = await select(gcashFeeConfig).get();
if (existing.isNotEmpty) return;
final defaultBrackets = [
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1.0,
maxAmount: 500.0,
fixedFee: const Value(10.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 501.0,
maxAmount: 1000.0,
fixedFee: const Value(15.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1001.0,
maxAmount: 1500.0,
fixedFee: const Value(20.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 1501.0,
maxAmount: 2000.0,
fixedFee: const Value(25.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 2001.0,
maxAmount: 3000.0,
fixedFee: const Value(35.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 3001.0,
maxAmount: 5000.0,
fixedFee: const Value(50.0),
percentageFee: const Value(0.0),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 5001.0,
maxAmount: 10000.0,
fixedFee: const Value(0.0),
percentageFee: const Value(1.5),
),
GcashFeeConfigCompanion.insert(
id: _uuid.v4(),
minAmount: 10001.0,
maxAmount: 50000.0,
fixedFee: const Value(0.0),
percentageFee: const Value(2.0),
),
];
for (final bracket in defaultBrackets) {
await into(gcashFeeConfig).insert(bracket);
}
}
/// Upsert a fee bracket with cloud sync queueing
Future<void> saveFeeBracket(GcashFeeConfigCompanion bracket) async {
await into(gcashFeeConfig).insertOnConflictUpdate(bracket);
await _enqueueSync(
entityTable: 'gcash_fee_config',
entityId: bracket.id.value,
operation: 'UPSERT',
payload: {
'id': bracket.id.value,
'minAmount': bracket.minAmount.value,
'maxAmount': bracket.maxAmount.value,
'fixedFee': bracket.fixedFee.present ? bracket.fixedFee.value : 0.0,
'percentageFee': bracket.percentageFee.present ? bracket.percentageFee.value : 0.0,
},
);
}
/// Delete a fee bracket with sync queueing
Future<void> deleteFeeBracket(String id) async {
await (delete(gcashFeeConfig)..where((b) => b.id.equals(id))).go();
await _enqueueSync(
entityTable: 'gcash_fee_config',
entityId: id,
operation: 'DELETE',
payload: {'id': id},
);
}
/// Calculate convenience fee for a given amount using active brackets
Future<double> calculateGcashFee(double amount) async {
final brackets = await getFeeBrackets();
for (final b in brackets) {
if (amount >= b.minAmount && amount <= b.maxAmount) {
if (b.percentageFee > 0) {
return (amount * (b.percentageFee / 100.0));
}
return b.fixedFee;
}
}
// Default fallback brackets if table is empty
if (amount <= 500) return 10.0;
if (amount <= 1000) return 15.0;
if (amount <= 1500) return 20.0;
if (amount <= 2000) return 25.0;
if (amount <= 3000) return 35.0;
if (amount <= 5000) return 50.0;
return (amount * 0.015);
}
/// Watch real-time dual-float balances (GCash E-Wallet vs Physical Cash Drawer)
Stream<DualFloatBalance> watchDualFloatBalance({
double baseCash = 5000.0,
double baseGcash = 10000.0,
}) {
return watchGcashTransactions().asyncMap((txList) async {
final cashSales = await (select(sales)..where((s) => s.paymentMethod.equals('CASH'))).get();
double gcash = baseGcash;
double cash = baseCash;
double fees = 0.0;
int cashIn = 0;
int cashOut = 0;
int storePay = 0;
for (final tx in txList) {
fees += tx.feeCharged;
if (tx.transactionType == 'CASH_IN') {
cashIn++;
gcash -= tx.amount; // Store sent GCash
cash += (tx.amount + tx.feeCharged); // Store received cash + fee
} else if (tx.transactionType == 'CASH_OUT') {
cashOut++;
gcash += tx.amount; // Store received GCash
cash -= (tx.amount - tx.feeCharged); // Cash given to customer minus fee
} else if (tx.transactionType == 'STORE_PAYMENT') {
storePay++;
gcash += tx.amount; // Direct store sale paid to GCash
} else if (tx.transactionType == 'FLOAT_ADJUSTMENT') {
gcash += tx.amount;
}
}
for (final s in cashSales) {
cash += s.totalAmount;
}
return DualFloatBalance(
gcashFloat: gcash,
cashDrawer: cash,
totalFeesEarned: fees,
totalTransactions: txList.length,
cashInCount: cashIn,
cashOutCount: cashOut,
storePaymentCount: storePay,
);
});
}
// --------------------------------------------------------------------------
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
class GcashOcrResult {
final String? referenceNumber;
final double? amount;
final String rawText;
final bool isSuccess;
const GcashOcrResult({
this.referenceNumber,
this.amount,
required this.rawText,
required this.isSuccess,
});
}
class GcashReceiptOcrService {
/// Primary regex for extracting 13-digit GCash reference number
/// Handles spaced formats: '1002 481 029412', '0021 482 910245', or solid '1002481029412'
static final RegExp refRegex = RegExp(
r'(?:Ref(?:\.|erence)?(?:\s*No\.?)?[:\s]*|Reference\s*ID[:\s]*)?([0-9]{4}[\s\-]?[0-9]{3,4}[\s\-]?[0-9]{5,6}|[0-9]{13})',
caseSensitive: false,
);
/// Secondary regex for extracting Peso amounts: 'PHP 500.00', 'P 1,000.00', 'Php500'
static final RegExp amountRegex = RegExp(
r'(?:PHP|Php|P|₱)\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{2})?)',
caseSensitive: false,
);
/// Parse text directly using regex slotting
static GcashOcrResult parseRawText(String text) {
String? foundRef;
double? foundAmount;
// Search for 13-digit reference number
final refMatches = refRegex.allMatches(text);
for (final match in refMatches) {
final rawMatch = match.group(1) ?? match.group(0);
if (rawMatch != null) {
final digitsOnly = rawMatch.replaceAll(RegExp(r'[^0-9]'), '');
if (digitsOnly.length == 13) {
foundRef = digitsOnly;
break;
}
}
}
// Search for Amount
final amountMatch = amountRegex.firstMatch(text);
if (amountMatch != null) {
final amtStr = amountMatch.group(1)?.replaceAll(',', '');
if (amtStr != null) {
foundAmount = double.tryParse(amtStr);
}
}
return GcashOcrResult(
referenceNumber: foundRef,
amount: foundAmount,
rawText: text,
isSuccess: foundRef != null,
);
}
/// Process an image file from camera / gallery using Google ML Kit
static Future<GcashOcrResult> processReceiptImage(String imagePath) async {
// Fallback on platforms without Google ML Kit support (e.g. desktop/web during tests)
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
return const GcashOcrResult(
rawText: 'MLKit OCR not supported on desktop/web platform. Use manual entry or mobile device.',
isSuccess: false,
);
}
try {
final inputImage = InputImage.fromFilePath(imagePath);
final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
final RecognizedText recognizedText = await textRecognizer.processImage(inputImage);
await textRecognizer.close();
return parseRawText(recognizedText.text);
} catch (e) {
return GcashOcrResult(
rawText: 'Error performing OCR: $e',
isSuccess: false,
);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,303 @@
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../core/database/app_database.dart';
enum RateCardFormat {
a4Sheet,
a5HalfSheet,
posThermal80mm,
posThermal58mm,
}
class RateCardPdfService {
/// Generate printable rate card PDF bytes
static Future<Uint8List> generateRateCardPdf({
required String storeName,
required List<GcashFeeBracket> brackets,
RateCardFormat format = RateCardFormat.a4Sheet,
String? gcashNumber,
}) async {
final pdf = pw.Document();
final PdfPageFormat pageFormat;
switch (format) {
case RateCardFormat.a4Sheet:
pageFormat = PdfPageFormat.a4;
break;
case RateCardFormat.a5HalfSheet:
pageFormat = PdfPageFormat.a5;
break;
case RateCardFormat.posThermal80mm:
pageFormat = const PdfPageFormat(
80 * PdfPageFormat.mm,
double.infinity,
marginAll: 4 * PdfPageFormat.mm,
);
break;
case RateCardFormat.posThermal58mm:
pageFormat = const PdfPageFormat(
58 * PdfPageFormat.mm,
double.infinity,
marginAll: 3 * PdfPageFormat.mm,
);
break;
}
final isThermal = format == RateCardFormat.posThermal80mm ||
format == RateCardFormat.posThermal58mm;
pdf.addPage(
pw.Page(
pageFormat: pageFormat,
build: (pw.Context context) {
if (isThermal) {
return _buildThermalLayout(
storeName: storeName,
brackets: brackets,
gcashNumber: gcashNumber,
is58mm: format == RateCardFormat.posThermal58mm,
);
}
return _buildStandardSheetLayout(
storeName: storeName,
brackets: brackets,
gcashNumber: gcashNumber,
isA5: format == RateCardFormat.a5HalfSheet,
);
},
),
);
return pdf.save();
}
/// Print or share rate card using OS printing dialog
static Future<void> printOrShareRateCard({
required String storeName,
required List<GcashFeeBracket> brackets,
RateCardFormat format = RateCardFormat.a4Sheet,
String? gcashNumber,
}) async {
final bytes = await generateRateCardPdf(
storeName: storeName,
brackets: brackets,
format: format,
gcashNumber: gcashNumber,
);
await Printing.layoutPdf(
onLayout: (PdfPageFormat _) async => bytes,
name: 'TindaGrid_${storeName}_GCash_Rate_Card',
);
}
/// High-contrast layout for A4/A5 store posters
static pw.Widget _buildStandardSheetLayout({
required String storeName,
required List<GcashFeeBracket> brackets,
String? gcashNumber,
required bool isA5,
}) {
final titleSize = isA5 ? 18.0 : 24.0;
final subtitleSize = isA5 ? 12.0 : 14.0;
final rowFontSize = isA5 ? 11.0 : 13.0;
return pw.Container(
padding: pw.EdgeInsets.all(isA5 ? 12 : 20),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.blue900, width: 3),
borderRadius: pw.BorderRadius.circular(12),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// Header Banner
pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: const pw.BoxDecoration(
color: PdfColors.blue800,
borderRadius: pw.BorderRadius.all(pw.Radius.circular(8)),
),
child: pw.Column(
children: [
pw.Text(
'GCASH CASH-IN & CASH-OUT',
style: pw.TextStyle(
fontSize: titleSize,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
textAlign: pw.TextAlign.center,
),
pw.SizedBox(height: 4),
pw.Text(
storeName.toUpperCase(),
style: pw.TextStyle(
fontSize: subtitleSize,
fontWeight: pw.FontWeight.bold,
color: PdfColors.amber300,
),
textAlign: pw.TextAlign.center,
),
if (gcashNumber != null && gcashNumber.isNotEmpty) ...[
pw.SizedBox(height: 4),
pw.Text(
'GCash No: $gcashNumber',
style: pw.TextStyle(
fontSize: subtitleSize,
color: PdfColors.white,
),
),
],
],
),
),
pw.SizedBox(height: 16),
// Rates Table
pw.TableHelper.fromTextArray(
headers: ['TRANSACTION AMOUNT (HALAGA)', 'SERVICE FEE (PATONG)'],
headerStyle: pw.TextStyle(
fontSize: rowFontSize,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue700),
cellAlignment: pw.Alignment.centerLeft,
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
},
cellStyle: pw.TextStyle(fontSize: rowFontSize),
data: brackets.map((b) {
final rangeStr = 'P ${b.minAmount.toStringAsFixed(0)} - P ${b.maxAmount.toStringAsFixed(0)}';
final feeStr = b.percentageFee > 0
? '${b.percentageFee.toStringAsFixed(1)}%'
: 'P ${b.fixedFee.toStringAsFixed(2)}';
return [rangeStr, feeStr];
}).toList(),
border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5),
headerPadding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 10),
cellPadding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 10),
),
pw.SizedBox(height: 16),
// Security & Instructions Footer
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: pw.BorderRadius.circular(6),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'PAALALA SA MGA SUKI:',
style: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
fontSize: isA5 ? 9.0 : 10.0,
color: PdfColors.red800,
),
),
pw.SizedBox(height: 2),
pw.Text(
'* I-double check ang numero bago mag-confirm ng send.',
style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5),
),
pw.Text(
'* Ipakita ang Reference Number o SMS resibo sa tindera bago kunin ang pera.',
style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5),
),
pw.Text(
'* Bawal ang reused screenshot. Automatic na nade-detect ang duplicate reference numbers.',
style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5),
),
],
),
),
pw.Spacer(),
// Powered by TindaGrid footer
pw.Center(
child: pw.Text(
'Generated with TindaGrid - Offline-First Sari-Sari OS',
style: pw.TextStyle(
fontSize: 8,
color: PdfColors.grey600,
fontStyle: pw.FontStyle.italic,
),
),
),
],
),
);
}
/// POS Thermal Receipt layout (58mm / 80mm)
static pw.Widget _buildThermalLayout({
required String storeName,
required List<GcashFeeBracket> brackets,
String? gcashNumber,
required bool is58mm,
}) {
final baseFont = is58mm ? 8.0 : 10.0;
final headerFont = is58mm ? 10.0 : 12.0;
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Center(
child: pw.Text(
storeName.toUpperCase(),
style: pw.TextStyle(fontSize: headerFont, fontWeight: pw.FontWeight.bold),
),
),
pw.Center(
child: pw.Text(
'GCASH RATES',
style: pw.TextStyle(fontSize: baseFont, fontWeight: pw.FontWeight.bold),
),
),
if (gcashNumber != null && gcashNumber.isNotEmpty)
pw.Center(
child: pw.Text(
'No: $gcashNumber',
style: pw.TextStyle(fontSize: baseFont),
),
),
pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed),
...brackets.map((b) {
final rangeStr = '${b.minAmount.toStringAsFixed(0)}-${b.maxAmount.toStringAsFixed(0)}';
final feeStr = b.percentageFee > 0
? '${b.percentageFee.toStringAsFixed(1)}%'
: 'P${b.fixedFee.toStringAsFixed(0)}';
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 2),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(rangeStr, style: pw.TextStyle(fontSize: baseFont)),
pw.Text(
feeStr,
style: pw.TextStyle(fontSize: baseFont, fontWeight: pw.FontWeight.bold),
),
],
),
);
}),
pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed),
pw.Center(
child: pw.Text(
'Salamat sa Tiwala!',
style: pw.TextStyle(fontSize: baseFont, fontStyle: pw.FontStyle.italic),
),
),
],
);
}
}
+88
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "13.0.0"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args:
dependency: transitive
description:
@@ -89,6 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.4.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
@@ -440,6 +464,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.2.1"
google_mlkit_commons:
dependency: transitive
description:
name: google_mlkit_commons
sha256: "621e94de22fd0f1e902e593b892746d80ab7542b39c2552aa091d904eb82fc6f"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
google_mlkit_text_recognition:
dependency: "direct main"
description:
name: google_mlkit_text_recognition
sha256: e13685af5093c8be009a3555909b72a80e10cfe8f97752bcbdf99fa0ccbda810
url: "https://pub.dev"
source: hosted
version: "0.17.1"
graphs:
dependency: transitive
description:
@@ -480,6 +520,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
intl:
dependency: transitive
description:
@@ -696,6 +744,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -744,6 +800,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "6e40827f370d196e97c6611724d923a3d86c431ce8e2ce1e59edd03a67a1a6e6"
url: "https://pub.dev"
source: hosted
version: "3.13.1"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
@@ -776,6 +848,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
printing:
dependency: "direct main"
description:
name: printing
sha256: "0f72f29f2e08b8453b7a45b6c7781eb792b1da3468bf0910be0eb3b1d6781f49"
url: "https://pub.dev"
source: hosted
version: "5.15.1"
pub_semver:
dependency: transitive
description:
+3
View File
@@ -51,6 +51,9 @@ dependencies:
vibration: ^3.2.1
audioplayers: ^6.8.1
qr_flutter: ^4.1.0
pdf: ^3.13.1
printing: ^5.15.1
google_mlkit_text_recognition: ^0.17.1
dev_dependencies:
flutter_test:
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:tindagrid/core/database/app_database.dart';
import 'package:tindagrid/features/gcash/gcash_receipt_ocr_service.dart';
import 'package:tindagrid/features/gcash/rate_card_pdf_service.dart';
void main() {
late AppDatabase db;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
});
group('Phase 4: GCash Float Ledger, Fee Scheduler & Rate Card PDF Tests', () {
test('4.1 Dual-Float Tracking Engine calculates real-time balances and fee profit', () async {
const baseCash = 5000.0;
const baseGcash = 10000.0;
// 1. Initial State
var stream = db.watchDualFloatBalance(baseCash: baseCash, baseGcash: baseGcash);
var initial = await stream.first;
expect(initial.gcashFloat, equals(10000.0));
expect(initial.cashDrawer, equals(5000.0));
expect(initial.totalFeesEarned, equals(0.0));
// 2. Perform Cash-In: Customer deposits ₱500, pays ₱10 fee
// Cash Drawer gets ₱510. GCash float loses ₱500.
await db.recordGcash(
transactionType: 'CASH_IN',
amount: 500.0,
feeCharged: 10.0,
referenceNumber: '1002481029412',
customerName: 'Maria Santos',
customerPhone: '09171234567',
);
var afterCashIn = await stream.first;
expect(afterCashIn.gcashFloat, equals(9500.0));
expect(afterCashIn.cashDrawer, equals(5510.0));
expect(afterCashIn.totalFeesEarned, equals(10.0));
expect(afterCashIn.cashInCount, equals(1));
// 3. Perform Cash-Out: Customer withdraws ₱1,000, pays ₱15 fee
// GCash float receives ₱1,000. Cash Drawer pays out net ₱985 (₱1000 - ₱15 fee)
await db.recordGcash(
transactionType: 'CASH_OUT',
amount: 1000.0,
feeCharged: 15.0,
referenceNumber: '0021984210984',
customerName: 'Juan Dela Cruz',
customerPhone: '09189876543',
);
var afterCashOut = await stream.first;
expect(afterCashOut.gcashFloat, equals(10500.0));
expect(afterCashOut.cashDrawer, equals(4525.0));
expect(afterCashOut.totalFeesEarned, equals(25.0));
expect(afterCashOut.cashOutCount, equals(1));
// 4. Store Payment: Customer pays ₱200 grocery bill via GCash QR
// GCash float increases by ₱200. Cash drawer untouched.
await db.recordGcash(
transactionType: 'STORE_PAYMENT',
amount: 200.0,
feeCharged: 0.0,
referenceNumber: '9921048123901',
);
var afterPayment = await stream.first;
expect(afterPayment.gcashFloat, equals(10700.0));
expect(afterPayment.cashDrawer, equals(4525.0));
expect(afterPayment.storePaymentCount, equals(1));
});
test('4.1 Anti-Fraud: Reference number duplicate detection catches recycled receipts', () async {
await db.recordGcash(
transactionType: 'CASH_IN',
amount: 300.0,
feeCharged: 10.0,
referenceNumber: '1002 9481 02941',
);
// Check with exact string
expect(await db.isReferenceNumberUsed('1002 9481 02941'), isTrue);
// Check without spaces (sanitized comparison)
expect(await db.isReferenceNumberUsed('1002948102941'), isTrue);
// Check non-existing reference number
expect(await db.isReferenceNumberUsed('9999999999999'), isFalse);
});
test('4.2 Customizable Fee Brackets seeds, saves, and calculates tiered rates', () async {
// 1. Seed defaults
await db.seedDefaultFeeBrackets();
final brackets = await db.getFeeBrackets();
expect(brackets.length, greaterThanOrEqualTo(6));
// Verify default fee for ₱500 is ₱10.00
final fee500 = await db.calculateGcashFee(500.0);
expect(fee500, equals(10.0));
// Verify default fee for ₱800 is ₱15.00
final fee800 = await db.calculateGcashFee(800.0);
expect(fee800, equals(15.0));
// 2. Add custom bracket: ₱10,001 - ₱20,000 at 2.0%
await db.saveFeeBracket(
const GcashFeeConfigCompanion(
id: drift.Value('bracket-tier-high'),
minAmount: drift.Value(10001.0),
maxAmount: drift.Value(20000.0),
fixedFee: drift.Value(0.0),
percentageFee: drift.Value(2.0),
),
);
final fee15000 = await db.calculateGcashFee(15000.0);
expect(fee15000, equals(300.0)); // 2% of 15,000
});
test('4.3 Printable Rate Card PDF generates valid bytes for A4, A5, and Thermal formats', () async {
await db.seedDefaultFeeBrackets();
final brackets = await db.getFeeBrackets();
// Test A4 Sheet PDF
final a4Bytes = await RateCardPdfService.generateRateCardPdf(
storeName: 'Aling Nena Tindahan',
brackets: brackets,
format: RateCardFormat.a4Sheet,
gcashNumber: '0917-123-4567',
);
expect(a4Bytes, isNotEmpty);
expect(a4Bytes.length, greaterThan(1000));
// Test A5 Half Sheet PDF
final a5Bytes = await RateCardPdfService.generateRateCardPdf(
storeName: 'Aling Nena Tindahan',
brackets: brackets,
format: RateCardFormat.a5HalfSheet,
);
expect(a5Bytes, isNotEmpty);
// Test 80mm POS Thermal Receipt PDF
final thermalBytes = await RateCardPdfService.generateRateCardPdf(
storeName: 'Aling Nena Tindahan',
brackets: brackets,
format: RateCardFormat.posThermal80mm,
);
expect(thermalBytes, isNotEmpty);
});
test('4.4 On-Device Receipt OCR parses 13-digit Reference and Amount from raw text', () {
const smsText =
'You have received PHP 1,500.00 of GCash from Juan Dela Cruz (09181234567) on 10/20/2026. Ref. No. 1002 481 029412.';
final result = GcashReceiptOcrService.parseRawText(smsText);
expect(result.isSuccess, isTrue);
expect(result.referenceNumber, equals('1002481029412'));
expect(result.amount, equals(1500.0));
// Test solid reference format
const solidRefText = 'Transaction Successful! Amount: P 250.00 Ref No: 0021482910245';
final solidResult = GcashReceiptOcrService.parseRawText(solidRefText);
expect(solidResult.isSuccess, isTrue);
expect(solidResult.referenceNumber, equals('0021482910245'));
expect(solidResult.amount, equals(250.0));
});
});
}