-
Notifications
You must be signed in to change notification settings - Fork 15
feat: Auto scanning of receipts #3850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 4 commits
b91f06b
49fe16c
cdb19e9
5c4066d
f13fd7c
4f057dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,46 @@ | ||||||||||||||
| import { Injectable } from '@angular/core'; | ||||||||||||||
| import { DocumentScanner, ScanResult } from '@capacitor-mlkit/document-scanner'; | ||||||||||||||
| import { from, Observable } from 'rxjs'; | ||||||||||||||
| import { switchMap } from 'rxjs/operators'; | ||||||||||||||
|
|
||||||||||||||
| @Injectable({ | ||||||||||||||
| providedIn: 'root', | ||||||||||||||
| }) | ||||||||||||||
| export class MLKitDocumentScannerService { | ||||||||||||||
| scanDocument(isBulkMode = false): Observable<string> { | ||||||||||||||
| return from( | ||||||||||||||
| DocumentScanner.scanDocument({ | ||||||||||||||
| resultFormats: 'JPEG', | ||||||||||||||
| galleryImportAllowed: false, // Disable gallery import to prevent additional UI | ||||||||||||||
| pageLimit: isBulkMode ? 20 : 1, // Allow up to 20 pages in bulk mode, 1 in single mode | ||||||||||||||
| scannerMode: 'BASE', // Use basic mode to minimize editing UI | ||||||||||||||
| }), | ||||||||||||||
| ).pipe( | ||||||||||||||
| switchMap((result: ScanResult) => { | ||||||||||||||
| if (result.scannedImages && result.scannedImages.length > 0) { | ||||||||||||||
| // In bulk mode, we still return one image at a time to maintain compatibility | ||||||||||||||
| return from(this.convertFileToBase64(result.scannedImages[0])); | ||||||||||||||
|
||||||||||||||
| } | ||||||||||||||
| throw new Error('No document scanned'); | ||||||||||||||
|
||||||||||||||
| }), | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private async convertFileToBase64(fileUrl: string): Promise<string> { | ||||||||||||||
| try { | ||||||||||||||
| const response = await fetch(fileUrl); | ||||||||||||||
| const blob = await response.blob(); | ||||||||||||||
| return new Promise<string>((resolve, reject): void => { | ||||||||||||||
| const reader = new FileReader(); | ||||||||||||||
| reader.onloadend = (): void => { | ||||||||||||||
| const base64data = reader.result as string; | ||||||||||||||
| resolve(base64data); | ||||||||||||||
| }; | ||||||||||||||
| reader.onerror = (): void => reject(new Error('Failed to convert file to base64')); | ||||||||||||||
| reader.readAsDataURL(blob); | ||||||||||||||
| }); | ||||||||||||||
| } catch { | ||||||||||||||
| throw new Error('Failed to convert file to base64'); | ||||||||||||||
| } | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mind it! The error details are vanishing like a villain in my movies! The generic catch block swallows the original error information, making debugging difficult. Preserve the error details for better troubleshooting. - } catch {
- throw new Error('Failed to convert file to base64');
+ } catch (error) {
+ throw new Error(`Failed to convert file to base64: ${error.message || 'Unknown error'}`);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -704,14 +704,13 @@ export class DashboardPage { | |
| this.trackingService.dashboardActionSheetButtonClicked({ | ||
| Action: action, | ||
| }); | ||
| this.router.navigate([ | ||
| '/', | ||
| 'enterprise', | ||
| route, | ||
| { | ||
| navigate_back: true, | ||
| }, | ||
| ]); | ||
| const autoCaptureExpenseParams: Params = { | ||
| navigate_back: true, | ||
| }; | ||
| if (action === 'Auto capture expenses') { | ||
| autoCaptureExpenseParams.auto_capture_expenses = true; | ||
| } | ||
| this.router.navigate(['/', 'enterprise', route, autoCaptureExpenseParams]); | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -732,6 +731,12 @@ export class DashboardPage { | |
| cssClass: 'capture-receipt', | ||
| handler: this.actionSheetButtonsHandler('Add manually', 'add_edit_expense'), | ||
| }, | ||
| { | ||
| text: 'Auto capture expenses', | ||
| icon: 'assets/svg/camera.svg', | ||
| cssClass: 'capture-receipt', | ||
| handler: this.actionSheetButtonsHandler('Auto capture expenses', 'camera_overlay'), | ||
| }, | ||
|
Comment on lines
+734
to
+739
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Style matters, even in code! Same icon for different actions is like wearing the same costume in every scene! Both "Capture receipt" and "Auto capture expenses" buttons use the same icon Consider using a different icon for the auto capture feature, perhaps one that indicates automation or bulk scanning. 🤖 Prompt for AI Agents |
||
| ]; | ||
|
|
||
| if (mileageEnabled) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,9 +1,10 @@ | ||||||||||||||||||||||||||||||
| import { Component, EventEmitter, OnDestroy, OnInit, Input, AfterViewInit, ViewChild, Inject } from '@angular/core'; | ||||||||||||||||||||||||||||||
| import { Component, EventEmitter, OnDestroy, OnInit, Input, AfterViewInit, ViewChild } from '@angular/core'; | ||||||||||||||||||||||||||||||
| import { inject } from '@angular/core'; | ||||||||||||||||||||||||||||||
| import { CameraPreviewPictureOptions } from '@capacitor-community/camera-preview'; | ||||||||||||||||||||||||||||||
| import { ModalController, NavController, PopoverController } from '@ionic/angular'; | ||||||||||||||||||||||||||||||
| import { ReceiptPreviewComponent } from './receipt-preview/receipt-preview.component'; | ||||||||||||||||||||||||||||||
| import { TrackingService } from 'src/app/core/services/tracking.service'; | ||||||||||||||||||||||||||||||
| import { Router } from '@angular/router'; | ||||||||||||||||||||||||||||||
| import { Router, ActivatedRoute } from '@angular/router'; | ||||||||||||||||||||||||||||||
| import { TransactionsOutboxService } from 'src/app/core/services/transactions-outbox.service'; | ||||||||||||||||||||||||||||||
| import { ImagePicker } from '@awesome-cordova-plugins/image-picker/ngx'; | ||||||||||||||||||||||||||||||
| import { concat, forkJoin, from, noop, Observable } from 'rxjs'; | ||||||||||||||||||||||||||||||
|
|
@@ -23,6 +24,7 @@ import { SnackbarPropertiesService } from 'src/app/core/services/snackbar-proper | |||||||||||||||||||||||||||||
| import { AuthService } from 'src/app/core/services/auth.service'; | ||||||||||||||||||||||||||||||
| import { CameraService } from 'src/app/core/services/camera.service'; | ||||||||||||||||||||||||||||||
| import { CameraPreviewService } from 'src/app/core/services/camera-preview.service'; | ||||||||||||||||||||||||||||||
| import { MLKitDocumentScannerService } from 'src/app/core/services/mlkit-document-scanner.service'; | ||||||||||||||||||||||||||||||
| import { ReceiptPreviewData } from 'src/app/core/models/receipt-preview-data.model'; | ||||||||||||||||||||||||||||||
| import { TranslocoService } from '@jsverse/transloco'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -65,26 +67,45 @@ export class CaptureReceiptComponent implements OnInit, OnDestroy, AfterViewInit | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| nativeSettings = NativeSettings; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| constructor( | ||||||||||||||||||||||||||||||
| private modalController: ModalController, | ||||||||||||||||||||||||||||||
| private trackingService: TrackingService, | ||||||||||||||||||||||||||||||
| private router: Router, | ||||||||||||||||||||||||||||||
| private navController: NavController, | ||||||||||||||||||||||||||||||
| private transactionsOutboxService: TransactionsOutboxService, | ||||||||||||||||||||||||||||||
| private imagePicker: ImagePicker, | ||||||||||||||||||||||||||||||
| private networkService: NetworkService, | ||||||||||||||||||||||||||||||
| private popoverController: PopoverController, | ||||||||||||||||||||||||||||||
| private loaderService: LoaderService, | ||||||||||||||||||||||||||||||
| private orgService: OrgService, | ||||||||||||||||||||||||||||||
| private platformEmployeeSettingsService: PlatformEmployeeSettingsService, | ||||||||||||||||||||||||||||||
| private matSnackBar: MatSnackBar, | ||||||||||||||||||||||||||||||
| private snackbarProperties: SnackbarPropertiesService, | ||||||||||||||||||||||||||||||
| private authService: AuthService, | ||||||||||||||||||||||||||||||
| private cameraService: CameraService, | ||||||||||||||||||||||||||||||
| private cameraPreviewService: CameraPreviewService, | ||||||||||||||||||||||||||||||
| @Inject(DEVICE_PLATFORM) private devicePlatform: 'android' | 'ios' | 'web', | ||||||||||||||||||||||||||||||
| private translocoService: TranslocoService, | ||||||||||||||||||||||||||||||
| ) {} | ||||||||||||||||||||||||||||||
| private readonly modalController = inject(ModalController); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly trackingService = inject(TrackingService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly router = inject(Router); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly route = inject(ActivatedRoute); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly navController = inject(NavController); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly transactionsOutboxService = inject(TransactionsOutboxService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly imagePicker = inject(ImagePicker); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly networkService = inject(NetworkService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly popoverController = inject(PopoverController); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly loaderService = inject(LoaderService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly orgService = inject(OrgService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly platformEmployeeSettingsService = inject(PlatformEmployeeSettingsService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly matSnackBar = inject(MatSnackBar); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly snackbarProperties = inject(SnackbarPropertiesService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly authService = inject(AuthService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly cameraService = inject(CameraService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly cameraPreviewService = inject(CameraPreviewService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly mlkitScanner = inject(MLKitDocumentScannerService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly devicePlatform = inject<'android' | 'ios' | 'web'>(DEVICE_PLATFORM); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private readonly translocoService = inject(TranslocoService); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| setupNetworkWatcher(): void { | ||||||||||||||||||||||||||||||
| const networkWatcherEmitter = new EventEmitter<boolean>(); | ||||||||||||||||||||||||||||||
|
|
@@ -515,6 +536,13 @@ export class CaptureReceiptComponent implements OnInit, OnDestroy, AfterViewInit | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| setUpAndStartCamera(): void { | ||||||||||||||||||||||||||||||
| // Check if the URL contains auto_capture_expenses=true | ||||||||||||||||||||||||||||||
| const autoCaptureParam = this.route.snapshot.params?.['auto_capture_expenses'] as string | undefined; | ||||||||||||||||||||||||||||||
| if (autoCaptureParam === 'true') { | ||||||||||||||||||||||||||||||
|
Comment on lines
+539
to
+541
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dei! The parameter reading is like looking for keys in the wrong pocket! You're reading - const autoCaptureParam = this.route.snapshot.params?.['auto_capture_expenses'] as string | undefined;
+ const autoCaptureParam = this.route.snapshot.queryParams?.['auto_capture_expenses'] as string | undefined;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| this.startDirectDocumentScan(); | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| this.cameraPreview.setUpAndStartCamera(); | ||||||||||||||||||||||||||||||
| if (this.transactionsOutboxService.singleCaptureCount === 3) { | ||||||||||||||||||||||||||||||
| this.showBulkModeToastMessage(); | ||||||||||||||||||||||||||||||
|
|
@@ -525,4 +553,73 @@ export class CaptureReceiptComponent implements OnInit, OnDestroy, AfterViewInit | |||||||||||||||||||||||||||||
| stopCamera(): void { | ||||||||||||||||||||||||||||||
| this.cameraPreview.stopCamera(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // New direct document scanning functions | ||||||||||||||||||||||||||||||
| startDirectDocumentScan(): void { | ||||||||||||||||||||||||||||||
| this.mlkitScanner.scanDocument(false).subscribe({ | ||||||||||||||||||||||||||||||
| next: (base64PictureData) => { | ||||||||||||||||||||||||||||||
| // Navigate directly to expense form with the scanned image | ||||||||||||||||||||||||||||||
| const isInstafyleEnabled$ = this.platformEmployeeSettingsService | ||||||||||||||||||||||||||||||
| .get() | ||||||||||||||||||||||||||||||
| .pipe( | ||||||||||||||||||||||||||||||
| map( | ||||||||||||||||||||||||||||||
| (employeeSettings) => | ||||||||||||||||||||||||||||||
| employeeSettings.insta_fyle_settings.allowed && employeeSettings.insta_fyle_settings.enabled, | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| isInstafyleEnabled$.subscribe((isInstafyleEnabled) => { | ||||||||||||||||||||||||||||||
| this.router.navigate([ | ||||||||||||||||||||||||||||||
| '/', | ||||||||||||||||||||||||||||||
| 'enterprise', | ||||||||||||||||||||||||||||||
| 'add_edit_expense', | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| dataUrl: base64PictureData, | ||||||||||||||||||||||||||||||
| canExtractData: isInstafyleEnabled, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| error: () => { | ||||||||||||||||||||||||||||||
| this.matSnackBar.openFromComponent(ToastMessageComponent, { | ||||||||||||||||||||||||||||||
| ...this.snackbarProperties.setSnackbarProperties('failure', { | ||||||||||||||||||||||||||||||
| message: 'failed', | ||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| error: () => { | |
| this.matSnackBar.openFromComponent(ToastMessageComponent, { | |
| ...this.snackbarProperties.setSnackbarProperties('failure', { | |
| message: 'failed', | |
| }), | |
| }); | |
| }, | |
| error: () => { | |
| this.matSnackBar.openFromComponent(ToastMessageComponent, { | |
| ...this.snackbarProperties.setSnackbarProperties('failure', { | |
| message: this.translocoService.translate('captureReceipt.documentScanFailed'), | |
| }), | |
| }); | |
| }, |
🤖 Prompt for AI Agents
In src/app/shared/components/capture-receipt/capture-receipt.component.ts around
lines 583 to 589, the error message shown in the snackbar is too generic and not
internationalized. Replace the hardcoded 'failed' message with a clear,
user-friendly message that is internationalized using the app's translation
service or i18n framework to provide proper feedback to users in their language.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Mind it! Code duplication is like a remake without improvements!
The logic for navigating to expense form with InstaFyle check is duplicated between startDirectDocumentScan() (lines 562-581) and navigateToExpenseForm() (lines 204-224).
Extract the common logic and reuse:
startDirectDocumentScan(): void {
this.mlkitScanner.scanDocument(false).subscribe({
next: (base64PictureData) => {
- // Navigate directly to expense form with the scanned image
- const isInstafyleEnabled$ = this.platformEmployeeSettingsService
- .get()
- .pipe(
- map(
- (employeeSettings) =>
- employeeSettings.insta_fyle_settings.allowed && employeeSettings.insta_fyle_settings.enabled,
- ),
- );
-
- isInstafyleEnabled$.subscribe((isInstafyleEnabled) => {
- this.router.navigate([
- '/',
- 'enterprise',
- 'add_edit_expense',
- {
- dataUrl: base64PictureData,
- canExtractData: isInstafyleEnabled,
- },
- ]);
- });
+ this.base64ImagesWithSource = [{
+ source: 'MOBILE_DASHCAM_SINGLE',
+ base64Image: base64PictureData,
+ }];
+ this.navigateToExpenseForm();
},Also applies to: 204-224
🤖 Prompt for AI Agents
In src/app/shared/components/capture-receipt/capture-receipt.component.ts
between lines 558-591 and 204-224, the logic for navigating to the expense form
with the InstaFyle check is duplicated. Extract this common logic into a new
reusable private method that accepts the base64PictureData and handles the
InstaFyle settings check and navigation. Replace the duplicated code in both
startDirectDocumentScan() and navigateToExpenseForm() by calling this new method
with the appropriate data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kabali says: Incomplete implementation is worse than no implementation!
The startDirectBulkDocumentScan() method doesn't actually enable bulk mode in the scanner - it calls scanDocument(true) but immediately processes and navigates after the first scan, defeating the bulk purpose.
The method should either:
- Loop to capture multiple documents, or
- Pass the correct bulk flag to actually scan multiple pages at once
Currently, it's misleading as it behaves like single capture with extra steps.
🤖 Prompt for AI Agents
In src/app/shared/components/capture-receipt/capture-receipt.component.ts around
lines 593 to 624, the startDirectBulkDocumentScan() method calls
scanDocument(true) but processes only one scanned image before navigating away,
so it does not actually perform bulk scanning. To fix this, modify the method to
either loop and accumulate multiple scans before processing or ensure the
scanner is invoked in a mode that captures multiple pages in one call, then
process all images together before navigating. This will align the method
behavior with its intended bulk scanning purpose.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Mind it! These new packages need my blessing for security!
The new dependencies look stylish, but let me verify they're the latest and safest versions for my fans to use.
Also applies to: 54-54
🌐 Web query:
💡 Result:
As of August 7, 2025, the latest versions of the specified npm packages are:
@capacitor-mlkit/document-scanner: The most recent version is
7.2.1, released on April 7, 2025. (classic.yarnpkg.com)@capacitor/filesystem: The latest stable version is
7.0.1, published 18 days ago. (npmjs.com)Please note that
@capacitor/filesystemhas a nightly build version7.0.2-nightly-20250527T150614.0, released 19 days ago. (npmjs.com)For the most current information, you can visit the npm package pages:
@capacitor-mlkit/document-scanner: (classic.yarnpkg.com)
@capacitor/filesystem: (npmjs.com)
Citations:
I will verify the version of @capacitor/filesystem in package.json now, mighty developer!
🏁 Script executed:
Length of output: 84
Double-check published versions of new dependencies, Superstar!
I see two package versions that leap beyond the latest npm releases:
• Latest published is 7.2.1 (yarnpkg.com)
• Latest stable is 7.0.1 (npmjs.com)
Please confirm these versions actually exist on npm or adjust them down to the current stable releases. Rajini says: get this right at once!
🤖 Prompt for AI Agents