Compare commits

...

3 Commits

Author SHA1 Message Date
Daeng Deni Mardaeni
5b235def37 feat(webstatement): tambah field password untuk proteksi PDF statement
Perubahan yang dilakukan:
- Menambahkan kolom password (nullable) pada tabel print_statement_logs melalui migrasi baru.
- Menambahkan field password di model PrintStatementLog dengan atribut hidden untuk keamanan serialisasi.
- Menambahkan input password pada form request print statement.
- Menambahkan validasi sisi klien agar password minimal 6 karakter.
- Menambahkan konfirmasi melalui SweetAlert untuk pengisian password dan email tujuan.
- Menambahkan index pada kolom password untuk optimasi pencarian jika dibutuhkan.
- Menggunakan field password untuk proteksi file PDF melalui PDFPasswordProtect.
- Menambahkan helper text dan placeholder pada form untuk meningkatkan pengalaman pengguna.
- Menambahkan atribut autocomplete="new-password" untuk menghindari autofill browser yang tidak aman.
- Menjaga kompatibilitas ke belakang dengan membuat field bersifat opsional (nullable).

Tujuan perubahan:
- Memberikan opsi proteksi file PDF dengan password yang diatur oleh pengguna.
- Meningkatkan keamanan distribusi file statement melalui email.
- Memastikan pengalaman pengguna tetap aman dan nyaman saat mengatur proteksi.
2025-07-10 14:33:26 +07:00
Daeng Deni Mardaeni
593a4f0d9c feat(webstatement): tambah enkripsi password pada PDF statement
Perubahan yang dilakukan:
- Menambahkan PDFPasswordProtect::encrypt di dalam ExportStatementPeriodJob.
- Mengikuti pola implementasi yang telah digunakan pada CombinePdfJob.
- PDF statement kini otomatis diproteksi menggunakan password.
- Password diambil dari konfigurasi: webstatement.pdf_password.
- Menambahkan logging untuk memantau proses proteksi PDF.
- Menjamin pengelolaan file sementara berjalan aman dan rapi.
- Menjaga kompatibilitas ke belakang (backward compatible) dengan sistem PDF yang sudah ada.

Tujuan perubahan:
- Meningkatkan keamanan file PDF dengan proteksi password standar perusahaan.
- Memastikan proses enkripsi berjalan otomatis tanpa mengubah alur penggunaan yang ada.
- Memberikan visibilitas terhadap proses proteksi melalui log sistem.
2025-07-10 14:13:16 +07:00
Daeng Deni Mardaeni
d4e6a3d73d feat(webstatement): ekstrak generatePassword ke helper
Perubahan yang dilakukan:
- Memindahkan fungsi `generatePassword` dari `CombinePdfController` ke `helpers.php` untuk peningkatan reusabilitas.
- Menambahkan dependency `use Carbon\Carbon` dan `use Modules\Webstatement\Models\Account` di `helpers.php`.
- Menyesuaikan pemanggilan fungsi `generatePassword` di `CombinePdfController` dengan versi helper.

Tujuan perubahan:
- Mengurangi duplikasi kode dengan menjadikan fungsi `generatePassword` dapat diakses secara global.
- Mempermudah perawatan kode melalui pemisahan tanggung jawab fungsi.

Signed-off-by: Daeng Deni Mardaeni <ddeni05@gmail.com>
2025-07-10 14:12:10 +07:00
8 changed files with 199 additions and 101 deletions

View File

@@ -1,6 +1,8 @@
<?php
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Webstatement\Models\Account;
use Modules\Webstatement\Models\ProvinceCore;
if(!function_exists('calculatePeriodDates')) {
@@ -41,7 +43,54 @@ use Modules\Webstatement\Models\ProvinceCore;
if(!function_exists('getProvinceCoreName')){
function getProvinceCoreName($code){
$province = ProvinceCore::where('code',$code)->first();
return $province->name;
return $code;
}
}
}
if(!function_exists('generatePassword')){
function generatePassword(Account $account)
{
$customer = $account->customer;
$accountNumber = $account->account_number;
// Get last 2 digits of account number
$lastTwoDigits = substr($accountNumber, -2);
// Determine which date to use based on sector
$dateToUse = null;
if ($customer && $customer->sector) {
$firstDigitSector = substr($customer->sector, 0, 1);
if ($firstDigitSector === '1') {
// Use date_of_birth if available, otherwise birth_incorp_date
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
} else {
// Use birth_incorp_date for sector > 1
$dateToUse = $customer->birth_incorp_date;
}
}
// If no date found, fallback to account number
if (!$dateToUse) {
Log::warning("No date found for account {$accountNumber}, using account number as password");
return $accountNumber;
}
try {
// Parse the date and format it
$date = Carbon::parse($dateToUse);
$day = $date->format('d');
$month = $date->format('M'); // 3-letter month abbreviation
$year = $date->format('Y');
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
$password = $day . $month . $year . $lastTwoDigits;
return $password;
} catch (\Exception $e) {
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
return $accountNumber; // Fallback to account number
}
}
}

View File

@@ -135,7 +135,7 @@ class CombinePdfController extends Controller
try {
// Generate password based on customer relation data
$password = $this->generatePassword($account);
$password = generatePassword($account);
// Dispatch job to combine PDFs or apply password protection
CombinePdfJob::dispatch($pdfFiles, $outputDir, $outputFilename, $password, $output_destination, $branchCode, $period);
@@ -158,58 +158,4 @@ class CombinePdfController extends Controller
'period' => $period
]);
}
/**
* Generate password based on customer relation data
* Format: date+end 2 digit account_number
* Example: 05Oct202585
*
* @param Account $account
* @return string
*/
private function generatePassword(Account $account)
{
$customer = $account->customer;
$accountNumber = $account->account_number;
// Get last 2 digits of account number
$lastTwoDigits = substr($accountNumber, -2);
// Determine which date to use based on sector
$dateToUse = null;
if ($customer && $customer->sector) {
$firstDigitSector = substr($customer->sector, 0, 1);
if ($firstDigitSector === '1') {
// Use date_of_birth if available, otherwise birth_incorp_date
$dateToUse = $customer->date_of_birth ?: $customer->birth_incorp_date;
} else {
// Use birth_incorp_date for sector > 1
$dateToUse = $customer->birth_incorp_date;
}
}
// If no date found, fallback to account number
if (!$dateToUse) {
Log::warning("No date found for account {$accountNumber}, using account number as password");
return $accountNumber;
}
try {
// Parse the date and format it
$date = Carbon::parse($dateToUse);
$day = $date->format('d');
$month = $date->format('M'); // 3-letter month abbreviation
$year = $date->format('Y');
// Format: ddMmmyyyyXX (e.g., 05Oct202585)
$password = $day . $month . $year . $lastTwoDigits;
return $password;
} catch (\Exception $e) {
Log::error("Error parsing date for account {$accountNumber}: {$e->getMessage()}");
return $accountNumber; // Fallback to account number
}
}
}

View File

@@ -110,7 +110,7 @@ ini_set('max_execution_time', 300000);
$validated['failed_count'] = 0;
$validated['stmt_sent_type'] = $request->input('stmt_sent_type') ? implode(",",$request->input('stmt_sent_type')) : '';
$validated['branch_code'] = $validated['branch_code'] ?? $branch_code; // Awal tidak tersedia
$validated['password'] = $request->input('password') ?? '';
// Create the statement log
$statement = PrintStatementLog::create($validated);

View File

@@ -30,6 +30,7 @@ use Modules\Webstatement\Models\{
};
use Modules\Basicdata\Models\Branch;
use Illuminate\Foundation\Bus\Dispatchable;
use Owenoj\PDFPasswordProtect\Facade\PDFPasswordProtect;
class ExportStatementPeriodJob implements ShouldQueue
{
@@ -469,10 +470,10 @@ class ExportStatementPeriodJob implements ShouldQueue
$saldoAwalBulan = (object) ['actual_balance' => (float) $this->saldo];
// Generate filename
$filename = "statement_{$this->account_number}_{$this->period}.pdf";
$filename = "{$this->account_number}_{$this->period}.pdf";
// Tentukan path storage
$storagePath = "statements/{$this->period}/{$this->account_number}";
$storagePath = "statements/{$this->period}/{$account->branch_code}";
$tempPath = storage_path("app/temp/{$filename}");
$fullStoragePath = "{$storagePath}/{$filename}";
@@ -502,6 +503,8 @@ class ExportStatementPeriodJob implements ShouldQueue
'html_length' => strlen($html)
]);
// Di dalam fungsi generatePdf(), setelah Browsershot::html()->save($tempPath)
// Generate PDF menggunakan Browsershot
Browsershot::html($html)
->showBackground()
@@ -518,6 +521,28 @@ class ExportStatementPeriodJob implements ShouldQueue
throw new Exception('PDF file gagal dibuat');
}
$printLog = PrintStatementLog::find($this->statementId);
// Apply password protection jika diperlukan
$password = $printLog->password ?? generatePassword($account); // Ambil dari config atau set default
if (!empty($password)) {
$tempProtectedPath = storage_path("app/temp/protected_{$filename}");
// Encrypt PDF dengan password
PDFPasswordProtect::encrypt($tempPath, $tempProtectedPath, $password);
// Ganti file original dengan yang sudah diproteksi
if (file_exists($tempProtectedPath)) {
unlink($tempPath); // Hapus file original
rename($tempProtectedPath, $tempPath); // Rename protected file ke original path
Log::info('ExportStatementPeriodJob: PDF password protection applied', [
'account_number' => $this->account_number,
'period' => $this->period
]);
}
}
$fileSize = filesize($tempPath);
// Pindahkan file ke storage permanen
@@ -525,7 +550,7 @@ class ExportStatementPeriodJob implements ShouldQueue
Storage::put($fullStoragePath, $pdfContent);
// Update print statement log
$printLog = PrintStatementLog::find($this->statementId);
if ($printLog) {
$printLog->update([
'is_available' => true,
@@ -580,20 +605,12 @@ class ExportStatementPeriodJob implements ShouldQueue
private function exportToCsv(): void
{
// Determine the base path based on client
$basePath = !empty($this->client)
? "statements/{$this->client}"
: "statements";
$account = Account::where('account_number', $this->account_number)->first();
// Create client directory if it doesn't exist
if (!empty($this->client)) {
Storage::disk($this->disk)->makeDirectory($basePath);
}
$storagePath = "statements/{$this->period}/{$account->branch_code}";
Storage::disk($this->disk)->makeDirectory($storagePath);
// Create account directory
$accountPath = "{$basePath}/{$this->account_number}";
Storage::disk($this->disk)->makeDirectory($accountPath);
$filePath = "{$accountPath}/{$this->fileName}";
$filePath = "{$storagePath}/{$this->fileName}";
// Delete existing file if it exists
if (Storage::disk($this->disk)->exists($filePath)) {

View File

@@ -247,12 +247,13 @@ class GenerateMultiAccountPdfJob implements ShouldQueue
Browsershot::html($html)
->showBackground()
->setOption('addStyleTag', json_encode(['content' => '@page { margin: 0; }']))
->setOption('protocolTimeout', 2147483) // 2 menit timeout
->format('A4')
->margins(0, 0, 0, 0)
->waitUntilNetworkIdle()
->timeout(60000)
->waitUntil('load')
->timeout(2147483)
->save($pdfPath);
// Verify file was created
if (!file_exists($pdfPath)) {
throw new Exception('PDF file was not created');

View File

@@ -46,6 +46,7 @@ class PrintStatementLog extends Model
'email_sent_at',
'stmt_sent_type',
'is_generated',
'password', // Tambahan field password
];
protected $casts = [
@@ -60,6 +61,10 @@ class PrintStatementLog extends Model
'target_accounts' => 'array',
];
protected $hidden = [
'password', // Hide password dari serialization
];
/**
* Get the formatted period display
*

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Menjalankan migrasi untuk menambahkan kolom password ke tabel print_statement_logs
*
* @return void
*/
public function up(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
// Menambahkan kolom password setelah kolom stmt_sent_type
$table->string('password', 255)->nullable()->after('stmt_sent_type')
->comment('Password untuk proteksi PDF statement');
// Menambahkan index untuk performa query jika diperlukan
$table->index(['password'], 'idx_print_statement_logs_password');
});
}
/**
* Membalikkan migrasi dengan menghapus kolom password
*
* @return void
*/
public function down(): void
{
Schema::table('print_statement_logs', function (Blueprint $table) {
// Hapus index terlebih dahulu
$table->dropIndex('idx_print_statement_logs_password');
// Hapus kolom password
$table->dropColumn('password');
});
}
};

View File

@@ -20,7 +20,7 @@
@endif
<div class="grid grid-cols-1 gap-5">
@if ($multiBranch)
@if (!$multiBranch)
<div class="form-group">
<label class="form-label required" for="branch_code">Branch/Cabang</label>
<select
@@ -107,6 +107,22 @@
@enderror
</div>
<!-- Tambahan field password -->
<div class="form-group">
<label class="form-label" for="password">PDF Password</label>
<input type="password"
class="input form-control @error('password') border-danger bg-danger-light @enderror"
id="password" name="password" value="{{ old('password', $statement->password ?? '') }}"
placeholder="Optional password untuk proteksi PDF statement" autocomplete="new-password">
<div class="mt-1 text-xs text-primary">
<i class="text-sm ki-outline ki-information-5"></i>
Jika dikosongkan password default statement akan diberlakukan
</div>
@error('password')
<div class="text-sm alert text-danger">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label class="form-label required" for="start_date">Start Date</label>
@@ -141,7 +157,8 @@
</div>
<div class="col-span-6">
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="statement-table" data-api-url="{{ route('statements.datatables') }}">
data-datatable-state-save="false" id="statement-table"
data-api-url="{{ route('statements.datatables') }}">
<div class="flex-wrap py-5 card-header">
<div class="min-w-full card card-grid" data-datatable="false" data-datatable-page-size="10"
data-datatable-state-save="false" id="statement-table"
@@ -218,9 +235,6 @@
<select class="w-16 select select-sm" data-datatable-size="true"
name="perpage"> </select>
per page
<select class="w-16 select select-sm" data-datatable-size="true"
name="perpage"> </select>
per page
</div>
<div class="flex gap-4 items-center">
<div class="flex gap-4 items-center">
@@ -273,51 +287,76 @@
}
/**
* Konfirmasi email sebelum submit form
* Menampilkan SweetAlert jika email diisi untuk konfirmasi pengiriman
* Konfirmasi password dan email sebelum submit form
* Menampilkan SweetAlert jika password atau email diisi untuk konfirmasi
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
// Log: Inisialisasi event listener untuk konfirmasi email
console.log('Email confirmation listener initialized');
// Log: Inisialisasi event listener untuk konfirmasi
console.log('Form confirmation listener initialized');
form.addEventListener('submit', function(e) {
const emailValue = emailInput.value.trim();
const passwordValue = passwordInput.value.trim();
// Jika email diisi, tampilkan konfirmasi
let confirmationNeeded = false;
let confirmationMessage = '';
// Jika email diisi
if (emailValue) {
confirmationNeeded = true;
confirmationMessage += `• Statement akan dikirim ke email: ${emailValue}\n`;
}
// Jika password diisi
if (passwordValue) {
confirmationNeeded = true;
confirmationMessage += `• PDF akan diproteksi dengan password\n`;
}
// Jika ada yang perlu dikonfirmasi
if (confirmationNeeded) {
e.preventDefault(); // Hentikan submit form sementara
// Log: Email terdeteksi, menampilkan konfirmasi
console.log('Email detected:', emailValue);
// Log: Konfirmasi diperlukan
console.log('Confirmation needed:', {
email: emailValue,
hasPassword: !!passwordValue
});
Swal.fire({
title: 'Konfirmasi Pengiriman Email',
text: `Apakah Anda yakin ingin mengirimkan statement ke email: ${emailValue}?`,
title: 'Konfirmasi Request Statement',
text: `Mohon konfirmasi pengaturan berikut:\n\n${confirmationMessage}\nApakah Anda yakin ingin melanjutkan?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, Kirim Email',
confirmButtonText: 'Ya, Lanjutkan',
cancelButtonText: 'Batal',
reverseButtons: true
reverseButtons: true,
preConfirm: () => {
// Validasi password jika diisi
if (passwordValue && passwordValue.length < 6) {
Swal.showValidationMessage('Password minimal 6 karakter');
return false;
}
return true;
}
}).then((result) => {
if (result.isConfirmed) {
// Log: User konfirmasi pengiriman email
console.log('User confirmed email sending');
// Log: User konfirmasi
console.log('User confirmed form submission');
// Submit form setelah konfirmasi
form.submit();
} else {
// Log: User membatalkan pengiriman email
console.log('User cancelled email sending');
// Log: User membatalkan
console.log('User cancelled form submission');
}
});
} else {
// Log: Tidak ada email, submit form normal
console.log('No email provided, submitting form normally');
}
});
});
@@ -351,7 +390,7 @@
account_number: {
title: 'Account Number',
render: (item, data) => {
if(data.request_type=="multi_account"){
if (data.request_type == "multi_account") {
return data.stmt_sent_type ?? 'N/A';
}
return data.account_number ?? '';