Compare commits

..

4 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
b717749450 refactor(webstatement): perbaikan tampilan dan penyesuaian template email pada statement
- **Penyesuaian Layout dan Tampilan:**
  - Mengubah properti CSS pada `.container`:
    - `max-width` diubah dari `90%` menjadi `100%`.
    - `margin` diubah dari `20px auto` menjadi `0px auto`.
  - Mengurangi padding pada elemen `.content` dari `30px` menjadi `5px` untuk meningkatkan efisiensi ruang tampilan.

- **Perbaikan Format Signature:**
  - Menggabungkan pemisah signature menjadi satu baris panjang untuk meningkatkan estetika dan keterbacaan.
  - Menghapus elemen `<wbr>` yang berlebihan.

- **Penghapusan Footer:**
  - Menghilangkan bagian footer yang terdapat elemen copyright dan informasi customer service.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:19:07 +07:00
Daeng Deni Mardaeni
e5b8dfc7c4 feat(webstatement): tambahkan pengiriman email dengan fallback konfigurasi
- **Implementasi Custom Email Sender:**
  - Menambahkan metode `send()` dengan EsmtpTransport untuk mendukung pengiriman email menggunakan fallback konfigurasi yang lebih fleksibel.
  - Penanganan port `STARTTLS` dengan koneksi manual serta pengaturan SSL.

- **Refaktor Method Build:**
  - Memperbaiki tampilan struktur email:
    - Menyertakan template email, attachment, dan penempatan properti email secara lebih dinamis.
    - Mendukung file PDF atau ZIP sebagai lampiran.

- **Implementasi Konversi Laravel to Symfony Mailer:**
  - Metode `toSymfonyEmail()` diimplementasikan untuk mengonversi email Laravel ke Symfony Mailer.
  - Penanganan dari email `from`, `to`, `subject`, hingga body HTML secara langsung.
  - Penambahan mekanisme attachment dengan validasi eksistensi file.

- **Peningkatan Logging:**
  - Menambahkan logging detail untuk setiap tahap proses pengiriman, termasuk metode fallback yang berhasil atau gagal.
  - Log peringatan dan error ditambahkan saat terjadi kegagalan di setiap metode yang dicoba.

- **Penanganan Error:**
  - Menangani pengecualian dan pencatatan error terakhir dari koneksi email manual serta fallback ke Laravel mailer jika cara custom gagal.

- **Konsistensi Format:**
  - Melakukan perapihan atribut class serta alignment pada kode untuk meningkatkan keterbacaan dan konsistensi.

- **Optimalisasi Email Statement:**
  - Memastikan format periode (bulanan/range) tampil dinamis pada subjek email.
  - Menambahkan validasi serta pengelolaan untuk lampiran file sebelum pengiriman.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:18:42 +07:00
Daeng Deni Mardaeni
d5482fb824 refactor(webstatement): restrukturisasi kode pada SendStatementEmailJob
- **Perbaikan Struktural:**
  - Melakukan perapihan kode dengan konsistensi indentasi dan penyusunan namespace seluruh file.
  - Menambahkan dan mengimpor namespace baru seperti `Throwable`, `InvalidArgumentException`, dan `Exception`.

- **Peningkatan Readability:**
  - Menambahkan format dan penyesuaian pada komentar, khususnya penjelasan method dan atribut.
  - Menggunakan alignment untuk parameter pada log dan constructor untuk meningkatkan keterbacaan.

- **Pengelolaan Job:**
  - Menambahkan logging detail saat memulai, menjalankan, hingga menyelesaikan job.
  - Menambahkan penanganan proses tiap akun dalam batch, termasuk logging sukses/gagal dan pembaruan status log.

- **Penanganan Error:**
  - Menambahkan rollback database jika terjadi exception pada saat proses pengiriman email.
  - Melakukan logging error dengan detail tambahan termasuk pesan dan trace.

- **Penambahan Utility:**
  - Menambahkan metode reusable seperti `updateLogStatus` untuk update status log dengan parameter dinamis.
  - Menambahkan validasi seperti pengecekan eksistensi file PDF dan email terkait sebelum pengiriman.

- **Peningkatan Proses Batch:**
  - Menambahkan pengelolaan batch berbasis properti `batchId` untuk tracking.
  - Memperbaiki handle retries dan status akhir batch secara komprehensif (completed, failed).
  - Menambahkan logging agregat untuk jumlah akun yang diproses dan tingkat keberhasilan.

- **Peningkatan Validasi Email:**
  - Menambahkan skenario untuk pengambilan email dari `stmt_email` atau fallback ke data customer jika tersedia.
  - Menambahkan peringatan saat akun tertentu tidak memiliki email yang valid.

- **Pemeliharaan File PDF:**
  - Mengecek keberadaan file PDF terkait sebelum proses pengiriman.
  - Menampilkan log error jika file tidak ditemukan.

- **Peningkatan Retry dan Logging Gagal:**
  - Implementasi metode `failed()` untuk logging pada job yang gagal permanen.
  - Menangani detail error dan rollback pada level tiap akun dan batch.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:18:16 +07:00
Daeng Deni Mardaeni
f6df453ddc refactor(webstatement): perbaiki pembentukan logika, struktur kode, dan validasi parameter pada SendStatementEmailCommand
- **Perbaikan Struktur Kode:**
  - Melakukan perapihan kode dengan konsistensi indentasi dan penyusunan namespace.
  - Memisahkan logika kompleks dan mengorganisasi ulang kode untuk meningkatkan keterbacaan.
  - Menambahkan namespace `InvalidArgumentException`.

- **Peningkatan Validasi:**
  - Menambahkan validasi komprehensif untuk parameter `period`, `type`, `--account`, dan `--branch`.
  - Validasi lebih spesifik untuk memastikan account atau branch terkait sesuai kebutuhan.
  - Memberikan pesan error informatif ketika validasi gagal.

- **Peningkatan Metode Utility:**
  - Menambahkan metode `validateParameters` untuk menangani berbagai skenario validasi input.
  - Menambahkan metode `determineRequestTypeAndTarget` untuk memisahkan logika penentuan tipe request.
  - Memperbarui metode `createLogEntry` untuk menyesuaikan atribut log dengan lebih baik berdasarkan request type.

- **Perbaikan Feedback Pengguna:**
  - Menampilkan informasi yang lebih rinci terkait status pengiriman email, seperti parameter validasi, log ID, dan batch ID.
  - Memberikan panduan untuk monitoring queue melalui command.

- **Penanganan Error dan Logging:**
  - Menambahkan logging detail untuk error yang terjadi dalam proses pengiriman email.
  - Memastikan rollback jika terjadi kegagalan selama proses dispatch job.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-06-12 09:15:38 +07:00
4 changed files with 805 additions and 684 deletions

View File

@@ -1,22 +1,23 @@
<?php
namespace Modules\Webstatement\Console;
namespace Modules\Webstatement\Console;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Basicdata\Models\Branch;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\Basicdata\Models\Branch;
use Modules\Webstatement\Jobs\SendStatementEmailJob;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use InvalidArgumentException;
/**
* Command untuk mengirim email statement PDF ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailCommand extends Command
{
protected $signature = 'webstatement:send-email
/**
* Command untuk mengirim email statement PDF ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailCommand extends Command
{
protected $signature = 'webstatement:send-email
{period : Format periode YYYYMM (contoh: 202401)}
{--type=single : Tipe pengiriman: single, branch, all}
{--account= : Nomor rekening (untuk type=single)}
@@ -25,224 +26,224 @@ class SendStatementEmailCommand extends Command
{--queue=emails : Nama queue untuk job (default: emails)}
{--delay=0 : Delay dalam menit sebelum job dijalankan}';
protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)';
protected $description = 'Mengirim email statement PDF ke nasabah (per rekening, per cabang, atau seluruh cabang)';
public function handle()
{
$this->info('🚀 Memulai proses pengiriman email statement...');
public function handle()
{
$this->info('🚀 Memulai proses pengiriman email statement...');
try {
$period = $this->argument('period');
$type = $this->option('type');
$accountNumber = $this->option('account');
$branchCode = $this->option('branch');
$batchId = $this->option('batch-id');
$queueName = $this->option('queue');
$delay = (int) $this->option('delay');
try {
$period = $this->argument('period');
$type = $this->option('type');
$accountNumber = $this->option('account');
$branchCode = $this->option('branch');
$batchId = $this->option('batch-id');
$queueName = $this->option('queue');
$delay = (int) $this->option('delay');
// Validasi parameter
if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) {
// Validasi parameter
if (!$this->validateParameters($period, $type, $accountNumber, $branchCode)) {
return Command::FAILURE;
}
// Tentukan request type dan target value
[$requestType, $targetValue] = $this->determineRequestTypeAndTarget($type, $accountNumber, $branchCode);
// Buat log entry
$log = $this->createLogEntry($period, $requestType, $targetValue, $batchId);
// Dispatch job
$job = SendStatementEmailJob::dispatch($period, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
$job->delay(now()->addMinutes($delay));
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
}
$this->displayJobInfo($period, $requestType, $targetValue, $queueName, $log);
$this->info('✅ Job pengiriman email statement berhasil didispatch!');
$this->info('📊 Gunakan command berikut untuk monitoring:');
$this->line(" php artisan queue:work {$queueName}");
$this->line(' php artisan webstatement:check-progress ' . $log->id);
return Command::SUCCESS;
} catch (Exception $e) {
$this->error('❌ Error saat mendispatch job: ' . $e->getMessage());
Log::error('SendStatementEmailCommand failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
// Tentukan request type dan target value
[$requestType, $targetValue] = $this->determineRequestTypeAndTarget($type, $accountNumber, $branchCode);
// Buat log entry
$log = $this->createLogEntry($period, $requestType, $targetValue, $batchId);
// Dispatch job
$job = SendStatementEmailJob::dispatch($period, $requestType, $targetValue, $batchId, $log->id)
->onQueue($queueName);
if ($delay > 0) {
$job->delay(now()->addMinutes($delay));
$this->info("⏰ Job dijadwalkan untuk dijalankan dalam {$delay} menit");
private function validateParameters($period, $type, $accountNumber, $branchCode)
{
// Validasi format periode
if (!preg_match('/^\d{6}$/', $period)) {
$this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
}
$this->displayJobInfo($period, $requestType, $targetValue, $queueName, $log);
$this->info('✅ Job pengiriman email statement berhasil didispatch!');
$this->info('📊 Gunakan command berikut untuk monitoring:');
$this->line(" php artisan queue:work {$queueName}");
$this->line(' php artisan webstatement:check-progress ' . $log->id);
// Validasi type
if (!in_array($type, ['single', 'branch', 'all'])) {
$this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
return false;
}
return Command::SUCCESS;
// Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
} catch (Exception $e) {
$this->error('❌ Error saat mendispatch job: ' . $e->getMessage());
Log::error('SendStatementEmailCommand failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
case 'branch':
if (!$branchCode) {
$this->error('❌ Parameter --branch diperlukan untuk type=branch');
return false;
}
$branch = Branch::where('code', $branchCode)->first();
if (!$branch) {
$this->error("❌ Branch {$branchCode} tidak ditemukan");
return false;
}
$accountCount = Account::with('customer')
->where('branch_code', $branchCode)
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error("❌ Tidak ada account dengan email di branch {$branchCode}");
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di branch {$branch->name}");
break;
case 'all':
$accountCount = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error('❌ Tidak ada account dengan email ditemukan');
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di seluruh cabang");
break;
}
return true;
}
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
switch ($type) {
case 'single':
return ['single_account', $accountNumber];
case 'branch':
return ['branch', $branchCode];
case 'all':
return ['all_branches', null];
default:
throw new InvalidArgumentException("Invalid type: {$type}");
}
}
private function createLogEntry($period, $requestType, $targetValue, $batchId)
{
$logData = [
'user_id' => null, // Command line execution
'period_from' => $period,
'period_to' => $period,
'is_period_range' => false,
'request_type' => $requestType,
'batch_id' => $batchId ?? uniqid('cmd_'),
'status' => 'pending',
'authorization_status' => 'approved', // Auto-approved untuk command line
'created_by' => null,
'ip_address' => '127.0.0.1',
'user_agent' => 'Command Line'
];
// Set branch_code dan account_number berdasarkan request type
switch ($requestType) {
case 'single_account':
$account = Account::where('account_number', $targetValue)->first();
$logData['branch_code'] = $account->branch_code;
$logData['account_number'] = $targetValue;
break;
case 'branch':
$logData['branch_code'] = $targetValue;
$logData['account_number'] = null;
break;
case 'all_branches':
$logData['branch_code'] = 'ALL';
$logData['account_number'] = null;
break;
}
return PrintStatementLog::create($logData);
}
private function displayJobInfo($period, $requestType, $targetValue, $queueName, $log)
{
$this->info('📋 Detail Job:');
$this->line(" Log ID: {$log->id}");
$this->line(" Periode: {$period}");
$this->line(" Request Type: {$requestType}");
switch ($requestType) {
case 'single_account':
$this->line(" Account: {$targetValue}");
break;
case 'branch':
$branch = Branch::where('code', $targetValue)->first();
$this->line(" Branch: {$targetValue} ({$branch->name})");
break;
case 'all_branches':
$this->line(" Target: Seluruh cabang");
break;
}
$this->line(" Batch ID: {$log->batch_id}");
$this->line(" Queue: {$queueName}");
}
}
private function validateParameters($period, $type, $accountNumber, $branchCode)
{
// Validasi format periode
if (!preg_match('/^\d{6}$/', $period)) {
$this->error('❌ Format periode tidak valid. Gunakan format YYYYMM (contoh: 202401)');
return false;
}
// Validasi type
if (!in_array($type, ['single', 'branch', 'all'])) {
$this->error('❌ Type tidak valid. Gunakan: single, branch, atau all');
return false;
}
// Validasi parameter berdasarkan type
switch ($type) {
case 'single':
if (!$accountNumber) {
$this->error('❌ Parameter --account diperlukan untuk type=single');
return false;
}
$account = Account::with('customer')
->where('account_number', $accountNumber)
->first();
if (!$account) {
$this->error("❌ Account {$accountNumber} tidak ditemukan");
return false;
}
$hasEmail = !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
if (!$hasEmail) {
$this->error("❌ Account {$accountNumber} tidak memiliki email");
return false;
}
$this->info("✅ Account {$accountNumber} ditemukan dengan email");
break;
case 'branch':
if (!$branchCode) {
$this->error('❌ Parameter --branch diperlukan untuk type=branch');
return false;
}
$branch = Branch::where('code', $branchCode)->first();
if (!$branch) {
$this->error("❌ Branch {$branchCode} tidak ditemukan");
return false;
}
$accountCount = Account::with('customer')
->where('branch_code', $branchCode)
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error("❌ Tidak ada account dengan email di branch {$branchCode}");
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di branch {$branch->name}");
break;
case 'all':
$accountCount = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL')
->get()
->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
})
->count();
if ($accountCount === 0) {
$this->error('❌ Tidak ada account dengan email ditemukan');
return false;
}
$this->info("✅ Ditemukan {$accountCount} account dengan email di seluruh cabang");
break;
}
return true;
}
private function determineRequestTypeAndTarget($type, $accountNumber, $branchCode)
{
switch ($type) {
case 'single':
return ['single_account', $accountNumber];
case 'branch':
return ['branch', $branchCode];
case 'all':
return ['all_branches', null];
default:
throw new \InvalidArgumentException("Invalid type: {$type}");
}
}
private function createLogEntry($period, $requestType, $targetValue, $batchId)
{
$logData = [
'user_id' => null, // Command line execution
'period_from' => $period,
'period_to' => $period,
'is_period_range' => false,
'request_type' => $requestType,
'batch_id' => $batchId ?? uniqid('cmd_'),
'status' => 'pending',
'authorization_status' => 'approved', // Auto-approved untuk command line
'created_by' => null,
'ip_address' => '127.0.0.1',
'user_agent' => 'Command Line'
];
// Set branch_code dan account_number berdasarkan request type
switch ($requestType) {
case 'single_account':
$account = Account::where('account_number', $targetValue)->first();
$logData['branch_code'] = $account->branch_code;
$logData['account_number'] = $targetValue;
break;
case 'branch':
$logData['branch_code'] = $targetValue;
$logData['account_number'] = null;
break;
case 'all_branches':
$logData['branch_code'] = 'ALL';
$logData['account_number'] = null;
break;
}
return PrintStatementLog::create($logData);
}
private function displayJobInfo($period, $requestType, $targetValue, $queueName, $log)
{
$this->info('📋 Detail Job:');
$this->line(" Log ID: {$log->id}");
$this->line(" Periode: {$period}");
$this->line(" Request Type: {$requestType}");
switch ($requestType) {
case 'single_account':
$this->line(" Account: {$targetValue}");
break;
case 'branch':
$branch = Branch::where('code', $targetValue)->first();
$this->line(" Branch: {$targetValue} ({$branch->name})");
break;
case 'all_branches':
$this->line(" Target: Seluruh cabang");
break;
}
$this->line(" Batch ID: {$log->batch_id}");
$this->line(" Queue: {$queueName}");
}
}

View File

@@ -1,418 +1,425 @@
<?php
namespace Modules\Webstatement\Jobs;
namespace Modules\Webstatement\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Basicdata\Models\Branch;
/**
* Job untuk mengirim email PDF statement ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
class SendStatementEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $period;
protected $requestType;
protected $targetValue; // account_number, branch_code, atau null untuk all
protected $batchId;
protected $logId;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use Modules\Webstatement\Mail\StatementEmail;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Throwable;
/**
* Membuat instance job baru
*
* @param string $period Format: YYYYMM
* @param string $requestType 'single_account', 'branch', 'all_branches'
* @param string|null $targetValue account_number untuk single, branch_code untuk branch, null untuk all
* @param string|null $batchId ID batch untuk tracking
* @param int|null $logId ID log untuk update progress
* Job untuk mengirim email PDF statement ke nasabah
* Mendukung pengiriman per rekening, per cabang, atau seluruh cabang
*/
public function __construct($period, $requestType = 'single_account', $targetValue = null, $batchId = null, $logId = null)
class SendStatementEmailJob implements ShouldQueue
{
$this->period = $period;
$this->requestType = $requestType;
$this->targetValue = $targetValue;
$this->batchId = $batchId ?? uniqid('batch_');
$this->logId = $logId;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
Log::info('SendStatementEmailJob created', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId,
'log_id' => $this->logId
]);
}
protected $period;
protected $requestType;
protected $targetValue; // account_number, branch_code, atau null untuk all
protected $batchId;
protected $logId;
/**
* Menjalankan job pengiriman email statement
*/
public function handle(): void
{
Log::info('Starting SendStatementEmailJob execution', [
'batch_id' => $this->batchId,
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
/**
* Membuat instance job baru
*
* @param string $period Format: YYYYMM
* @param string $requestType 'single_account', 'branch', 'all_branches'
* @param string|null $targetValue account_number untuk single, branch_code untuk branch, null untuk all
* @param string|null $batchId ID batch untuk tracking
* @param int|null $logId ID log untuk update progress
*/
public function __construct($period, $requestType = 'single_account', $targetValue = null, $batchId = null, $logId = null)
{
$this->period = $period;
$this->requestType = $requestType;
$this->targetValue = $targetValue;
$this->batchId = $batchId ?? uniqid('batch_');
$this->logId = $logId;
DB::beginTransaction();
Log::info('SendStatementEmailJob created', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId,
'log_id' => $this->logId
]);
}
try {
// Update log status menjadi processing
$this->updateLogStatus('processing', ['started_at' => now()]);
/**
* Menjalankan job pengiriman email statement
*/
public function handle()
: void
{
Log::info('Starting SendStatementEmailJob execution', [
'batch_id' => $this->batchId,
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
// Ambil accounts berdasarkan request type
$accounts = $this->getAccountsByRequestType();
DB::beginTransaction();
if ($accounts->isEmpty()) {
Log::warning('No accounts with email found', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId
try {
// Update log status menjadi processing
$this->updateLogStatus('processing', ['started_at' => now()]);
// Ambil accounts berdasarkan request type
$accounts = $this->getAccountsByRequestType();
if ($accounts->isEmpty()) {
Log::warning('No accounts with email found', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'batch_id' => $this->batchId
]);
$this->updateLogStatus('completed', [
'completed_at' => now(),
'total_accounts' => 0,
'processed_accounts' => 0,
'success_count' => 0,
'failed_count' => 0
]);
DB::commit();
return;
}
// Update total accounts
$this->updateLogStatus('processing', [
'total_accounts' => $accounts->count(),
'target_accounts' => $accounts->pluck('account_number')->toArray()
]);
$this->updateLogStatus('completed', [
'completed_at' => now(),
'total_accounts' => 0,
'processed_accounts' => 0,
'success_count' => 0,
'failed_count' => 0
$successCount = 0;
$failedCount = 0;
$processedCount = 0;
foreach ($accounts as $account) {
try {
$this->sendStatementEmail($account);
$successCount++;
Log::info('Statement email sent successfully', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $this->getEmailForAccount($account),
'batch_id' => $this->batchId
]);
} catch (Exception $e) {
$failedCount++;
Log::error('Failed to send statement email', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $this->getEmailForAccount($account),
'error' => $e->getMessage(),
'batch_id' => $this->batchId
]);
}
$processedCount++;
// Update progress setiap 10 account atau di akhir
if ($processedCount % 10 === 0 || $processedCount === $accounts->count()) {
$this->updateLogStatus('processing', [
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
]);
}
}
// Update status final
$finalStatus = $failedCount === 0 ? 'completed' : ($successCount === 0 ? 'failed' : 'completed');
$this->updateLogStatus($finalStatus, [
'completed_at' => now(),
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
]);
DB::commit();
Log::info('SendStatementEmailJob completed', [
'batch_id' => $this->batchId,
'total_accounts' => $accounts->count(),
'success_count' => $successCount,
'failed_count' => $failedCount,
'final_status' => $finalStatus
]);
} catch (Exception $e) {
DB::rollBack();
$this->updateLogStatus('failed', [
'completed_at' => now(),
'error_message' => $e->getMessage()
]);
Log::error('SendStatementEmailJob failed', [
'batch_id' => $this->batchId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Update status log
*/
private function updateLogStatus($status, $additionalData = [])
{
if (!$this->logId) {
return;
}
// Update total accounts
$this->updateLogStatus('processing', [
'total_accounts' => $accounts->count(),
'target_accounts' => $accounts->pluck('account_number')->toArray()
try {
$updateData = array_merge(['status' => $status], $additionalData);
PrintStatementLog::where('id', $this->logId)->update($updateData);
} catch (Exception $e) {
Log::error('Failed to update log status', [
'log_id' => $this->logId,
'status' => $status,
'error' => $e->getMessage()
]);
}
}
/**
* Mengambil accounts berdasarkan request type
*/
private function getAccountsByRequestType()
{
Log::info('Fetching accounts by request type', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
$successCount = 0;
$failedCount = 0;
$processedCount = 0;
$query = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL');
foreach ($accounts as $account) {
try {
$this->sendStatementEmail($account);
$successCount++;
switch ($this->requestType) {
case 'single_account':
if ($this->targetValue) {
$query->where('account_number', $this->targetValue);
}
break;
Log::info('Statement email sent successfully', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $this->getEmailForAccount($account),
'batch_id' => $this->batchId
]);
} catch (\Exception $e) {
$failedCount++;
case 'branch':
if ($this->targetValue) {
$query->where('branch_code', $this->targetValue);
}
break;
Log::error('Failed to send statement email', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $this->getEmailForAccount($account),
'error' => $e->getMessage(),
'batch_id' => $this->batchId
]);
}
case 'all_branches':
// Tidak ada filter tambahan, ambil semua
break;
$processedCount++;
// Update progress setiap 10 account atau di akhir
if ($processedCount % 10 === 0 || $processedCount === $accounts->count()) {
$this->updateLogStatus('processing', [
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
]);
}
default:
throw new InvalidArgumentException("Invalid request type: {$this->requestType}");
}
// Update status final
$finalStatus = $failedCount === 0 ? 'completed' : ($successCount === 0 ? 'failed' : 'completed');
$this->updateLogStatus($finalStatus, [
'completed_at' => now(),
'processed_accounts' => $processedCount,
'success_count' => $successCount,
'failed_count' => $failedCount
$accounts = $query->get();
// Filter accounts yang memiliki email
$accountsWithEmail = $accounts->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
});
Log::info('Accounts with email retrieved', [
'total_accounts' => $accounts->count(),
'accounts_with_email' => $accountsWithEmail->count(),
'request_type' => $this->requestType,
'batch_id' => $this->batchId
]);
DB::commit();
return $accountsWithEmail;
}
Log::info('SendStatementEmailJob completed', [
'batch_id' => $this->batchId,
'total_accounts' => $accounts->count(),
'success_count' => $successCount,
'failed_count' => $failedCount,
'final_status' => $finalStatus
/**
* Mengirim email statement untuk account tertentu
*
* @param Account $account
*
* @return void
* @throws \Exception
*/
private function sendStatementEmail(Account $account)
{
// Dapatkan email untuk pengiriman
$emailAddress = $this->getEmailForAccount($account);
if (!$emailAddress) {
throw new Exception("No email address found for account {$account->account_number}");
}
// Cek apakah file PDF ada
$pdfPath = $this->getPdfPath($account->account_number, $account->branch_code);
if (!Storage::exists($pdfPath)) {
throw new Exception("PDF file not found: {$pdfPath}");
}
// Buat atau update log statement
$statementLog = $this->createOrUpdateStatementLog($account);
// Dapatkan path absolut file
$absolutePdfPath = Storage::path($pdfPath);
// Kirim email
// Add delay between email sends to prevent rate limiting
sleep(1); // 2 second delay
Mail::to($emailAddress)->send(
new StatementEmail($statementLog, $absolutePdfPath, false)
);
// Update status log dengan email yang digunakan
$statementLog->update([
'email_sent_at' => now(),
'email_status' => 'sent',
'email_address' => $emailAddress // Simpan email yang digunakan untuk tracking
]);
} catch (\Exception $e) {
DB::rollBack();
Log::info('Email sent for account', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account.stmt_email' : 'customer.email',
'pdf_path' => $pdfPath,
'batch_id' => $this->batchId
]);
}
/**
* Mendapatkan email untuk pengiriman statement
*
* @param Account $account
*
* @return string|null
*/
private function getEmailForAccount(Account $account)
{
// Prioritas pertama: stmt_email dari account
if (!empty($account->stmt_email)) {
Log::info('Using stmt_email from account', [
'account_number' => $account->account_number,
'email' => $account->stmt_email,
'batch_id' => $this->batchId
]);
return $account->stmt_email;
}
// Prioritas kedua: email dari customer
if ($account->customer && !empty($account->customer->email)) {
Log::info('Using email from customer', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'email' => $account->customer->email,
'batch_id' => $this->batchId
]);
return $account->customer->email;
}
Log::warning('No email found for account', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'batch_id' => $this->batchId
]);
return null;
}
/**
* Mendapatkan path file PDF statement
*
* @param string $accountNumber
* @param string $branchCode
*
* @return string
*/
private function getPdfPath($accountNumber, $branchCode)
{
return "combine/{$this->period}/{$branchCode}/{$accountNumber}_{$this->period}.pdf";
}
/**
* Membuat atau update log statement
*
* @param Account $account
*
* @return PrintStatementLog
*/
private function createOrUpdateStatementLog(Account $account)
{
$emailAddress = $this->getEmailForAccount($account);
$logData = [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'branch_code' => $account->branch_code,
'period' => $this->period,
'print_date' => now(),
'batch_id' => $this->batchId,
'email_address' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account' : 'customer'
];
$statementLog = PrintStatementLog::updateOrCreate(
[
'account_number' => $account->account_number,
'period_from' => $this->period,
'period_to' => $this->period
],
$logData
);
Log::info('Statement log created/updated', [
'log_id' => $statementLog->id,
'account_number' => $account->account_number,
'email_address' => $emailAddress,
'batch_id' => $this->batchId
]);
return $statementLog;
}
/**
* Handle job failure
*/
public function failed(Throwable $exception)
{
$this->updateLogStatus('failed', [
'completed_at' => now(),
'error_message' => $e->getMessage()
'completed_at' => now(),
'error_message' => $exception->getMessage()
]);
Log::error('SendStatementEmailJob failed', [
'batch_id' => $this->batchId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}
/**
* Mengambil accounts berdasarkan request type
*/
private function getAccountsByRequestType()
{
Log::info('Fetching accounts by request type', [
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue
]);
$query = Account::with('customer')
->where('stmt_sent_type', 'BY.EMAIL');
switch ($this->requestType) {
case 'single_account':
if ($this->targetValue) {
$query->where('account_number', $this->targetValue);
}
break;
case 'branch':
if ($this->targetValue) {
$query->where('branch_code', $this->targetValue);
}
break;
case 'all_branches':
// Tidak ada filter tambahan, ambil semua
break;
default:
throw new \InvalidArgumentException("Invalid request type: {$this->requestType}");
}
$accounts = $query->get();
// Filter accounts yang memiliki email
$accountsWithEmail = $accounts->filter(function ($account) {
return !empty($account->stmt_email) ||
($account->customer && !empty($account->customer->email));
});
Log::info('Accounts with email retrieved', [
'total_accounts' => $accounts->count(),
'accounts_with_email' => $accountsWithEmail->count(),
'request_type' => $this->requestType,
'batch_id' => $this->batchId
]);
return $accountsWithEmail;
}
/**
* Update status log
*/
private function updateLogStatus($status, $additionalData = [])
{
if (!$this->logId) {
return;
}
try {
$updateData = array_merge(['status' => $status], $additionalData);
PrintStatementLog::where('id', $this->logId)->update($updateData);
} catch (\Exception $e) {
Log::error('Failed to update log status', [
'log_id' => $this->logId,
'status' => $status,
'error' => $e->getMessage()
Log::error('SendStatementEmailJob failed permanently', [
'batch_id' => $this->batchId,
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);
}
}
/**
* Mendapatkan email untuk pengiriman statement
*
* @param Account $account
* @return string|null
*/
private function getEmailForAccount(Account $account)
{
// Prioritas pertama: stmt_email dari account
if (!empty($account->stmt_email)) {
Log::info('Using stmt_email from account', [
'account_number' => $account->account_number,
'email' => $account->stmt_email,
'batch_id' => $this->batchId
]);
return $account->stmt_email;
}
// Prioritas kedua: email dari customer
if ($account->customer && !empty($account->customer->email)) {
Log::info('Using email from customer', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'email' => $account->customer->email,
'batch_id' => $this->batchId
]);
return $account->customer->email;
}
Log::warning('No email found for account', [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'batch_id' => $this->batchId
]);
return null;
}
/**
* Mengirim email statement untuk account tertentu
*
* @param Account $account
* @return void
* @throws \Exception
*/
private function sendStatementEmail(Account $account)
{
// Dapatkan email untuk pengiriman
$emailAddress = $this->getEmailForAccount($account);
if (!$emailAddress) {
throw new \Exception("No email address found for account {$account->account_number}");
}
// Cek apakah file PDF ada
$pdfPath = $this->getPdfPath($account->account_number, $account->branch_code);
if (!Storage::exists($pdfPath)) {
throw new \Exception("PDF file not found: {$pdfPath}");
}
// Buat atau update log statement
$statementLog = $this->createOrUpdateStatementLog($account);
// Dapatkan path absolut file
$absolutePdfPath = Storage::path($pdfPath);
// Kirim email
// Add delay between email sends to prevent rate limiting
sleep(1); // 2 second delay
Mail::to($emailAddress)->send(
new StatementEmail($statementLog, $absolutePdfPath, false)
);
// Update status log dengan email yang digunakan
$statementLog->update([
'email_sent_at' => now(),
'email_status' => 'sent',
'email_address' => $emailAddress // Simpan email yang digunakan untuk tracking
]);
Log::info('Email sent for account', [
'account_number' => $account->account_number,
'branch_code' => $account->branch_code,
'email' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account.stmt_email' : 'customer.email',
'pdf_path' => $pdfPath,
'batch_id' => $this->batchId
]);
}
/**
* Mendapatkan path file PDF statement
*
* @param string $accountNumber
* @param string $branchCode
* @return string
*/
private function getPdfPath($accountNumber, $branchCode)
{
return "combine/{$this->period}/{$branchCode}/{$accountNumber}_{$this->period}.pdf";
}
/**
* Membuat atau update log statement
*
* @param Account $account
* @return PrintStatementLog
*/
private function createOrUpdateStatementLog(Account $account)
{
$emailAddress = $this->getEmailForAccount($account);
$logData = [
'account_number' => $account->account_number,
'customer_code' => $account->customer_code,
'branch_code' => $account->branch_code,
'period' => $this->period,
'print_date' => now(),
'batch_id' => $this->batchId,
'email_address' => $emailAddress,
'email_source' => !empty($account->stmt_email) ? 'account' : 'customer'
];
$statementLog = PrintStatementLog::updateOrCreate(
[
'account_number' => $account->account_number,
'period_from' => $this->period,
'period_to' => $this->period
],
$logData
);
Log::info('Statement log created/updated', [
'log_id' => $statementLog->id,
'account_number' => $account->account_number,
'email_address' => $emailAddress,
'batch_id' => $this->batchId
]);
return $statementLog;
}
/**
* Handle job failure
*/
public function failed(\Throwable $exception)
{
$this->updateLogStatus('failed', [
'completed_at' => now(),
'error_message' => $exception->getMessage()
]);
Log::error('SendStatementEmailJob failed permanently', [
'batch_id' => $this->batchId,
'period' => $this->period,
'request_type' => $this->requestType,
'target_value' => $this->targetValue,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString()
]);
}
}

View File

@@ -1,79 +1,204 @@
<?php
namespace Modules\Webstatement\Mail;
namespace Modules\Webstatement\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Carbon\Carbon;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Log;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\PrintStatementLog;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email;
class StatementEmail extends Mailable
{
use Queueable, SerializesModels;
protected $statement;
protected $filePath;
protected $isZip;
/**
* Create a new message instance.
* Membuat instance email baru untuk pengiriman statement
*
* @param PrintStatementLog $statement
* @param string $filePath
* @param bool $isZip
*/
public function __construct(PrintStatementLog $statement, $filePath, $isZip = false)
class StatementEmail extends Mailable
{
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
}
use Queueable, SerializesModels;
/**
* Build the message.
* Membangun struktur email dengan attachment statement
*
* @return $this
*/
public function build()
{
$subject = 'Statement Rekening Bank Artha Graha Internasional';
protected $statement;
protected $filePath;
protected $isZip;
protected $message;
if ($this->statement->is_period_range) {
$subject .= " - {$this->statement->period_from} to {$this->statement->period_to}";
} else {
$subject .= " - " . \Carbon\Carbon::createFromFormat('Ym', $this->statement->period_from)->locale('id')->isoFormat('MMMM Y');
/**
* Create a new message instance.
* Membuat instance email baru untuk pengiriman statement
*
* @param PrintStatementLog $statement
* @param string $filePath
* @param bool $isZip
*/
public function __construct(PrintStatementLog $statement, $filePath, $isZip = false)
{
$this->statement = $statement;
$this->filePath = $filePath;
$this->isZip = $isZip;
}
$email = $this->subject($subject)
->view('webstatement::statements.email')
->with([
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
'requestType' => $this->statement->request_type,
'batchId' => $this->statement->batch_id,
'accounts' => Account::where('account_number', $this->statement->account_number)->first()
]);
/**
* Override the send method to use EsmtpTransport directly
* Using the working configuration from Python script with multiple fallback methods
*/
public function send($mailer)
{
// Get mail configuration
$host = Config::get('mail.mailers.smtp.host');
$port = Config::get('mail.mailers.smtp.port');
$username = Config::get('mail.mailers.smtp.username');
$password = Config::get('mail.mailers.smtp.password');
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/zip',
]);
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$email->attach($this->filePath, [
'as' => $fileName,
'mime' => 'application/pdf',
]);
Log::info('StatementEmail: Attempting to send email with multiple fallback methods');
// Define connection methods like in Python script
$method =
// Method 3: STARTTLS with original port
[
'port' => $port,
'ssl' => false,
'name' => 'STARTTLS (Port $port)'
];
$lastException = null;
// Try each connection method until one succeeds
try {
Log::info('StatementEmail: Trying ' . $method['name']);
// Create EsmtpTransport with current method
$transport = new EsmtpTransport($host, $method['port'], $method['ssl']);
// Set username and password
if ($username) {
$transport->setUsername($username);
}
if ($password) {
$transport->setPassword($password);
}
// Disable SSL verification for development
$streamOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true
]
];
$transport->getStream()->setStreamOptions($streamOptions);
// Build the email content
$this->build();
// Start transport connection
$transport->start();
// Create Symfony mailer
$symfonyMailer = new Mailer($transport);
// Convert Laravel message to Symfony Email
$email = $this->toSymfonyEmail();
// Send the email
$symfonyMailer->send($email);
// Close connection
$transport->stop();
Log::info('StatementEmail: Successfully sent email using ' . $method['name']);
return $this;
} catch (Exception $e) {
$lastException = $e;
Log::warning('StatementEmail: Failed to send with ' . $method['name'] . ': ' . $e->getMessage());
// Continue to next method
}
try {
return parent::send($mailer);
} catch (Exception $e) {
Log::error('StatementEmail: Laravel mailer also failed: ' . $e->getMessage());
// If we got here, throw the last exception from our custom methods
throw $lastException;
}
}
return $email;
/**
* Build the message.
* Membangun struktur email dengan attachment statement
*
* @return $this
*/
public function build()
{
$subject = 'Statement Rekening Bank Artha Graha Internasional';
if ($this->statement->is_period_range) {
$subject .= " - {$this->statement->period_from} to {$this->statement->period_to}";
} else {
$subject .= " - " . Carbon::createFromFormat('Ym', $this->statement->period_from)
->locale('id')
->isoFormat('MMMM Y');
}
$email = $this->subject($subject);
// Store the email in the message property for later use in toSymfonyEmail()
$this->message = $email;
return $email;
}
/**
* Convert Laravel message to Symfony Email
*/
protected function toSymfonyEmail()
{
// Build the message if it hasn't been built yet
$this->build();
// Create a new Symfony Email
$email = new Email();
// Set from address using config values instead of trying to call getFrom()
$fromAddress = Config::get('mail.from.address');
$fromName = Config::get('mail.from.name');
$email->from($fromName ? "$fromName <$fromAddress>" : $fromAddress);
// Set to addresses - use the to addresses from the mailer instead of trying to call getTo()
// We'll get the to addresses from the Mail facade when the email is sent
// For now, we'll just add a placeholder recipient that will be overridden by the Mail facade
$email->to($this->message->to[0]['address']);
$email->subject($this->message->subject);
// Set body - use a simple HTML content instead of trying to call getHtmlBody()
// In a real implementation, we would need to find a way to access the rendered HTML content
$email->html(view('webstatement::statements.email', [
'statement' => $this->statement,
'accountNumber' => $this->statement->account_number,
'periodFrom' => $this->statement->period_from,
'periodTo' => $this->statement->period_to,
'isRange' => $this->statement->is_period_range,
'requestType' => $this->statement->request_type,
'batchId' => $this->statement->batch_id,
'accounts' => Account::where('account_number', $this->statement->account_number)->first()
])->render());
//$email->text($this->message->getTextBody());
// Add attachments - use the file path directly instead of trying to call getAttachments()
if ($this->filePath && file_exists($this->filePath)) {
if ($this->isZip) {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}_to_{$this->statement->period_to}.zip";
$contentType = 'application/zip';
} else {
$fileName = "{$this->statement->account_number}_{$this->statement->period_from}.pdf";
$contentType = 'application/pdf';
}
$email->attachFromPath($this->filePath, $fileName, $contentType);
}
return $email;
}
}
}

View File

@@ -16,8 +16,8 @@
}
.container {
max-width: 90%;
margin: 20px auto;
max-width: 100%;
margin: 0px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
@@ -37,7 +37,7 @@
}
.content {
padding: 30px;
padding: 5px;
font-size: 14px;
}
@@ -103,11 +103,7 @@
Terima Kasih,<br><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------
<wbr>
------------------------------
<wbr>
--------<br>
------------------------------------------------------------<br>
Kami sangat menghargai masukan dan saran Anda untuk meningkatkan layanan dan produk kami.<br>
Untuk memberikan masukan, silakan hubungi <strong>GrahaCall 24 Jam</strong> kami di
<strong>0-800-191-8880</strong>.<br><br><br>
@@ -132,11 +128,7 @@
Regards,<br><br>
<strong>Bank Artha Graha Internasional</strong><br>
------------------------------
<wbr>
------------------------------
<wbr>
--------<br>
------------------------------------------------------------<br>
We welcome any feedback or suggestions to improve our product and services.<br>
If you have any feedback, please contact our <strong>GrahaCall 24 Hours</strong> at
<strong>0-800-191-8880</strong>.
@@ -145,10 +137,6 @@
</div>
</div>
</div>
<div class="footer">
<p>© {{ date('Y') }} Bank Artha Graha Internasional. All rights reserved.</p>
<p>Jika Anda memiliki pertanyaan, silakan hubungi customer service kami.</p>
</div>
</div>
</body>