feat(gcash): complete Phase 4 dual-float ledger, customizable fee scheduler, rate card PDF, and receipt OCR
This commit is contained in:
1 parent
b4ae4e0185
commit
ca07d6a308
7 files changed
+1848
-215
No files matched your search
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user