From 1f8efdccc4e59e331443a7818b81c855c1774f95 Mon Sep 17 00:00:00 2001 From: Baghiz Zuhdi Adzin <74885652+baghizzhd@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:34:54 +0700 Subject: [PATCH] pembayaran --- app/Events/PaymentCompleted.php | 19 + .../Admin/OfflinePaymentController.php | 218 +++++++ app/Http/Controllers/PaymentController.php | 14 +- app/Http/Controllers/SettingController.php | 25 +- .../student/BootcampPurchaseController.php | 16 + .../student/MyTeamPackageController.php | 15 + .../student/OfflinePaymentController.php | 68 +++ .../student/PurchaseController.php | 67 ++- .../student/TutorBookingController.php | 15 + .../student/VAPaymentController.php | 328 +++++++++++ .../SendVirtualAccountPaidNotification.php | 23 + app/Models/OfflinePayment.php | 24 + app/Models/VAPayment.php | 37 ++ app/Models/payment_gateway/Razorpay.php | 110 ---- app/Models/payment_gateway/StripePay.php | 119 ---- app/Notifications/CoursePaid.php | 37 ++ app/Notifications/CoursePurchaseCreated.php | 37 ++ app/Providers/EventServiceProvider.php | 18 +- public/assets/frontend/default/css/style.css | 4 +- public/assets/payment/style/css/style.css | 4 - public/assets/payment/virtualaccount.png | Bin 0 -> 4051 bytes .../views/admin/admin/permission.blade.php | 1 + resources/views/admin/navigation.blade.php | 13 +- .../admin/offline_payments/index.blade.php | 236 ++++++++ .../admin/setting/payment_setting.blade.php | 4 +- .../default/bootcamp/bootcamp_grid.blade.php | 11 + .../default/bootcamp/pricing_card.blade.php | 6 + .../student/my_courses/index.blade.php | 53 +- .../student/purchase_history/index.blade.php | 173 ++++-- resources/views/payment/.DS_Store | Bin 8196 -> 6148 bytes .../views/payment/offline/index.blade.php | 56 ++ .../views/payment/razorpay/index.blade.php | 64 --- .../views/payment/razorpay/payment.blade.php | 68 --- .../views/payment/stripe/index.blade.php | 71 --- resources/views/payment/va_show.blade.php | 403 +++++++++++++ .../payment/virtualaccount/index.blade.php | 52 ++ .../notifications/course_paid.blade.php | 502 ++++++++++++++++ .../course_purchased_created.blade.php | 538 ++++++++++++++++++ routes/admin.php | 10 + routes/payment.php | 6 +- routes/student.php | 18 +- 41 files changed, 2922 insertions(+), 561 deletions(-) create mode 100644 app/Events/PaymentCompleted.php create mode 100644 app/Http/Controllers/Admin/OfflinePaymentController.php create mode 100644 app/Http/Controllers/student/OfflinePaymentController.php create mode 100644 app/Http/Controllers/student/VAPaymentController.php create mode 100644 app/Listeners/SendVirtualAccountPaidNotification.php create mode 100644 app/Models/OfflinePayment.php create mode 100644 app/Models/VAPayment.php delete mode 100644 app/Models/payment_gateway/Razorpay.php delete mode 100644 app/Models/payment_gateway/StripePay.php create mode 100644 app/Notifications/CoursePaid.php create mode 100644 app/Notifications/CoursePurchaseCreated.php create mode 100644 public/assets/payment/virtualaccount.png create mode 100644 resources/views/admin/offline_payments/index.blade.php create mode 100644 resources/views/payment/offline/index.blade.php delete mode 100644 resources/views/payment/razorpay/index.blade.php delete mode 100644 resources/views/payment/razorpay/payment.blade.php delete mode 100644 resources/views/payment/stripe/index.blade.php create mode 100644 resources/views/payment/va_show.blade.php create mode 100644 resources/views/payment/virtualaccount/index.blade.php create mode 100644 resources/views/vendor/notifications/course_paid.blade.php create mode 100644 resources/views/vendor/notifications/course_purchased_created.blade.php diff --git a/app/Events/PaymentCompleted.php b/app/Events/PaymentCompleted.php new file mode 100644 index 0000000..7a3fc44 --- /dev/null +++ b/app/Events/PaymentCompleted.php @@ -0,0 +1,19 @@ +payment = $payment; + } +} diff --git a/app/Http/Controllers/Admin/OfflinePaymentController.php b/app/Http/Controllers/Admin/OfflinePaymentController.php new file mode 100644 index 0000000..873141a --- /dev/null +++ b/app/Http/Controllers/Admin/OfflinePaymentController.php @@ -0,0 +1,218 @@ +status == 'approved') { + $payments->where('status', 1); + } elseif ($request->status == 'suspended') { + $payments->where('status', 2); + } elseif ($request->status == 'pending') { + $payments->where('status', 0)->orWhere('status', null); + } + + $page_data['payments'] = $payments->paginate(10); + return view('admin.offline_payments.index', $page_data); + } + + public function download_doc($id) + { + // validate id + if (empty($id)) { + Session::flash('error', get_phrase('Data not found.')); + return redirect()->back(); + } + + // payment details + $payment_details = OfflinePayment::where('id', $id)->first(); + $filePath = public_path($payment_details->doc); + if (! file_exists($filePath)) { + Session::flash('error', get_phrase('Data not found.')); + return redirect()->back(); + } + // download file + return Response::download($filePath); + } + + public function accept_payment($id) + { + // validate id + if (empty($id)) { + Session::flash('error', get_phrase('Id can not be empty.')); + return redirect()->back(); + } + + // payment details + $query = OfflinePayment::where('id', $id)->where('status', 0); + if ($query->doesntExist()) { + Session::flash('error', get_phrase('Data not found.')); + return redirect()->back(); + } + + $payment_details = $query->first(); + + $payment['invoice'] = Str::random(20); + $payment['user_id'] = $payment_details['user_id']; + $payment['payment_type'] = 'offline'; + $payment['coupon'] = $payment_details->coupon; + + if ($payment_details->item_type == 'course') { + $items = json_decode($payment_details->items); + + foreach ($items as $item) { + $course = Course::where('id', $item)->first(); + $payment['course_id'] = $course->id; + + // Calculate base amount + $amount = $course->discount_flag == 1 ? $course->discounted_price : $course->price; + + // Apply coupon discount if applicable + $discounted_price = 0; + if ($payment_details->coupon) { + $coupon = Coupon::where('code', $payment_details->coupon)->first(); + if ($coupon) { + $discounted_price = $amount * ($coupon->discount / 100); + } + } + + // Final payment amount and tax + $final_amount = $amount - $discounted_price; + $payment['amount'] = $final_amount; + $payment['tax'] = (get_settings('course_selling_tax') / 100) * $final_amount; + + // Calculate admin and instructor revenue + if (get_course_creator_id($course->id)->role == 'admin') { + $payment['admin_revenue'] = $final_amount; + } else { + $payment['instructor_revenue'] = $final_amount * (get_settings('instructor_revenue') / 100); + $payment['admin_revenue'] = $final_amount - $payment['instructor_revenue']; + } + + // Insert payment record + $accept_payment = Payment_history::insert($payment); + + // Enroll user in course if payment succeeds + if ($accept_payment) { + + $enroll['user_id'] = $payment_details['user_id']; + $enroll['course_id'] = $course->id; + $enroll['enrollment_type'] = "paid"; + $enroll['entry_date'] = time(); + $enroll['created_at'] = date('Y-m-d H:i:s'); + $enroll['updated_at'] = date('Y-m-d H:i:s'); + + if ($course->expiry_period > 0) { + $days = $course->expiry_period * 30; + $enroll['expiry_date'] = strtotime("+" . $days . " days"); + } else { + $enroll['expiry_date'] = null; + } + + Enrollment::insert($enroll); + } + } + } elseif ($payment_details->item_type == 'bootcamp') { + $bootcamps = Bootcamp::whereIn('id', json_decode($payment_details->items, true))->get(); + foreach($bootcamps as $bootcamp){ + $bootcamp_payment['invoice'] = '#' . Str::random(20); + $bootcamp_payment['user_id'] = $payment_details['user_id']; + $bootcamp_payment['bootcamp_id'] = $bootcamp->id; + $bootcamp_payment['price'] = $bootcamp->discount_flag == 1 ? $bootcamp->price - $bootcamp->discounted_price : $bootcamp->price; + $bootcamp_payment['tax'] = 0; + $bootcamp_payment['payment_method'] = 'offline'; + $bootcamp_payment['status'] = 1; + + // insert bootcamp purchase + BootcampPurchase::insert($bootcamp_payment); + } + } elseif ($payment_details->item_type == 'package') { + $packages = TeamTrainingPackage::whereIn('id', json_decode($payment_details->items, true))->get(); + foreach($packages as $package){ + $package_payment['invoice'] = '#' . Str::random(20); + $package_payment['user_id'] = $payment_details['user_id']; + $package_payment['package_id'] = $package->id; + $package_payment['price'] = $package->price; + $package_payment['tax'] = 0; + $package_payment['payment_method'] = 'offline'; + $package_payment['status'] = 1; + + // insert package purchase + TeamPackagePurchase::insert($package_payment); + } + } elseif ($payment_details->item_type == 'tutor_booking') { + $schedules = TutorSchedule::whereIn('id', json_decode($payment_details->items, true))->get(); + foreach($schedules as $schedule){ + + $schedule_payment['invoice'] = '#' . Str::random(20); + $schedule_payment['student_id'] = $payment_details['user_id']; + $schedule_payment['schedule_id'] = $schedule->id; + $schedule_payment['price'] = $payment_details['total_amount']; + $schedule_payment['tax'] = $payment_details['tax']; + $schedule_payment['payment_method'] = 'offline'; + + $schedule = TutorSchedule::find($schedule->id); + if (get_user_info($schedule->tutor_id)->role == 'admin') { + $schedule_payment['admin_revenue'] = $payment_details['payable_amount']; + } else { + $schedule_payment['instructor_revenue'] = $payment_details['total_amount'] * (get_settings('instructor_revenue') / 100); + $schedule_payment['admin_revenue'] = $payment_details['total_amount'] - $schedule_payment['instructor_revenue']; + } + + $schedule_payment['tutor_id'] = $schedule->tutor_id; + $schedule_payment['start_time'] = $schedule->start_time; + $schedule_payment['end_time'] = $schedule->end_time; + + // insert tutor bookings + TutorBooking::insert($schedule_payment); + } + } + + // remove items from offline payment + OfflinePayment::where('id', $id)->update(['status' => 1]); + + // go back + Session::flash('success', 'Payment has been accepted.'); + return redirect()->route('admin.offline.payments'); + } + + public function decline_payment($id) + { + // remove items from offline payment + OfflinePayment::where('id', $id)->update(['status' => 2]); + + // go back + Session::flash('success', 'Payment has been suspended'); + return redirect()->route('admin.offline.payments'); + } + + public function delete_payment($id) + { + OfflinePayment::where('id', $id)->delete(); + Session::flash('success', get_phrase('Admin revenue delete successfully')); + return redirect()->route('admin.offline.payments'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index 9da64e4..0499090 100644 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers; +use App\Models\FileUploader; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; class PaymentController extends Controller { @@ -69,17 +72,6 @@ class PaymentController extends Controller return redirect()->to($created_payment_link); } - public function payment_razorpay($identifier) - { - $payment_details = session('payment_details'); - $payment_gateway = DB::table('payment_gateways')->where('identifier', $identifier)->first(); - $model_name = $payment_gateway->model_name; - $model_full_path = str_replace(' ', '', 'App\Models\payment_gateway\ ' . $model_name); - $data = $model_full_path::payment_create($identifier); - - return view('payment.razorpay.payment', compact('data')); - } - public function webRedirectToPayFee(Request $request) { // Check if the 'auth' query parameter is present diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index d5c4fc8..9131de7 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -238,26 +238,17 @@ class SettingController extends Controller $paypal = json_encode($paypal); $data = array_splice($data, 0, 4); $data['keys'] = $paypal; - } elseif ($request->identifier == 'stripe') { - $stripe = [ - 'public_key' => $data['public_key'], - 'secret_key' => $data['secret_key'], - 'public_live_key' => $data['public_live_key'], - 'secret_live_key' => $data['secret_live_key'], - ]; - $stripe = json_encode($stripe); - $data = array_splice($data, 0, 4); - $data['keys'] = $stripe; - } elseif ($request->identifier == 'razorpay') { - $razorpay = [ - 'public_key' => $data['public_key'], - 'secret_key' => $data['secret_key'], + } elseif ($request->identifier == 'offline' || $request->identifier == 'virtualaccount') { + $offline = [ + 'bank_information' => $data['bank_information'], ]; - $razorpay = json_encode($razorpay); + $offline = json_encode($offline); $data = array_splice($data, 0, 4); - $data['keys'] = $razorpay; - } + + $data['keys'] = $offline; + unset($data['bank_information']); + } Payment_gateway::where('identifier', $request->identifier)->update($data); } diff --git a/app/Http/Controllers/student/BootcampPurchaseController.php b/app/Http/Controllers/student/BootcampPurchaseController.php index d92f120..3a3bec6 100644 --- a/app/Http/Controllers/student/BootcampPurchaseController.php +++ b/app/Http/Controllers/student/BootcampPurchaseController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\student; use App\Http\Controllers\Controller; use App\Models\Bootcamp; +use App\Models\OfflinePayment; use App\Models\BootcampPurchase; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; @@ -48,6 +50,20 @@ class BootcampPurchaseController extends Controller Session::flash('success', get_phrase('Enrolled in the bootcamp successfully')); return redirect()->route('my.bootcamps'); } + + // check any offline processing data + $processing_payments = OfflinePayment::where([ + 'user_id' => auth()->user()->id, + 'items' => $bootcamp->id, + 'item_type' => 'bootcamp', + 'status' => 0, + ])->first(); + + if ($processing_payments) { + Session::flash('warning', get_phrase('Your request is in process.')); + return redirect()->back(); + } + // prepare bootcamp payment data $price = $bootcamp->discount_flag ? $bootcamp->price - $bootcamp->discounted_price : $bootcamp->price; diff --git a/app/Http/Controllers/student/MyTeamPackageController.php b/app/Http/Controllers/student/MyTeamPackageController.php index ecf0cc2..b79d453 100644 --- a/app/Http/Controllers/student/MyTeamPackageController.php +++ b/app/Http/Controllers/student/MyTeamPackageController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\student; use App\Http\Controllers\Controller; use App\Models\Enrollment; +use App\Models\OfflinePayment; use App\Models\TeamPackageMember; use App\Models\TeamPackagePurchase; use App\Models\TeamTrainingPackage; @@ -184,6 +185,20 @@ class MyTeamPackageController extends Controller return redirect()->back(); } + // check any offline processing data + $processing_payments = OfflinePayment::where([ + 'user_id' => auth()->user()->id, + 'items' => $package->id, + 'item_type' => 'team_package', + 'status' => 0, + ]) + ->first(); + + if ($processing_payments) { + Session::flash('warning', get_phrase('Your request is in process.')); + return redirect()->back(); + } + // prepare team package payment data $payment_details = [ 'items' => [ diff --git a/app/Http/Controllers/student/OfflinePaymentController.php b/app/Http/Controllers/student/OfflinePaymentController.php new file mode 100644 index 0000000..c952667 --- /dev/null +++ b/app/Http/Controllers/student/OfflinePaymentController.php @@ -0,0 +1,68 @@ + 'required|mimes:jpeg,jpg,pdf,txt,png,docx,doc|max:3072', + ]; + $validator = Validator::make($request->all(), $rules); + + if ($validator->fails()) { + return redirect()->back()->withErrors($validator)->withInput(); + } + + + $file = $request->doc; + $file_name = Str::random(20) . '.' . $file->extension(); + $path = 'uploads/offline_payment/' . slugify(auth()->user()->name) . '/' . $file_name; + FileUploader::upload($file, $path, null, null, 300); + + $offline_payment['user_id'] = auth()->user()->id; + $offline_payment['item_type'] = $request->item_type; + $offline_payment['items'] = json_encode($item_id_arr); + $offline_payment['tax'] = $payment_details['tax']; + $offline_payment['total_amount'] = $payment_details['payable_amount']; + $offline_payment['doc'] = $path; + $offline_payment['coupon'] = $payment_details['coupon'] ?? null; + + // insert offline payment history + OfflinePayment::insert($offline_payment); + + // remove from cart + if ($request->item_type == 'course') { + $url = 'purchase.history'; + CartItem::whereIn('course_id', $item_id_arr)->where('user_id', auth()->user()->id)->delete(); + } elseif ($request->item_type == 'bootcamp') { + $url = 'bootcamps'; + } elseif ($request->item_type == 'package') { + $url = 'team.packages'; + } elseif ($request->item_type == 'tutor_booking') { + $url = 'tutor_list'; + } + + // return to courses + Session::flash('success', get_phrase('The payment will be completed once the admin reviews and approves it.')); + return redirect()->route($url); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/student/PurchaseController.php b/app/Http/Controllers/student/PurchaseController.php index f24a430..a6bc411 100644 --- a/app/Http/Controllers/student/PurchaseController.php +++ b/app/Http/Controllers/student/PurchaseController.php @@ -7,21 +7,76 @@ use App\Models\CartItem; use App\Models\Course; use App\Models\Enrollment; use App\Models\Payment_history; +use App\Models\VAPayment; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class PurchaseController extends Controller { public function purchase_history() { - $page_data['payments'] = Payment_history::join('courses', 'payment_histories.course_id', 'courses.id') - ->join('users', 'payment_histories.user_id', 'users.id') - ->where('payment_histories.user_id', auth()->user()->id) - ->select('payment_histories.*', 'courses.title as course_title', 'users.name as user_name') - ->latest('id')->paginate(10); + $userId = auth()->id(); + + // 1. Query untuk Paid menggunakan Model Payment_history + $paid = Payment_history::query() + ->join('courses', 'payment_histories.course_id', '=', 'courses.id') + ->where('payment_histories.user_id', $userId) + ->select( + 'payment_histories.id', + 'courses.title as title', + 'payment_histories.amount', + 'payment_histories.payment_type', + 'payment_histories.created_at', + DB::raw('NULL as expired_at'), + DB::raw('"paid" as payment_status'), + DB::raw('payment_histories.id as invoice_id') + ); + + // 2. Query untuk Unpaid menggunakan Model VAPayment + $unpaid = VAPayment::query() + ->where('user_id', $userId) + ->where('status', 0) + ->where('expired_at', '>=', now()) + ->select( + 'id', + DB::raw('"Virtual Account BTN" as title'), + 'total_amount as amount', + DB::raw('"VA BTN" as payment_type'), + 'created_at', + 'expired_at', + DB::raw('"unpaid" as payment_status'), + DB::raw('NULL as invoice_id') + ); + + // 3. Query untuk Failed menggunakan Model VAPayment + $failed = VAPayment::query() + ->where('user_id', $userId) + ->where('status', 0) + ->where('expired_at', '<', now()) + ->select( + 'id', + DB::raw('"Virtual Account BTN" as title'), + 'total_amount as amount', + DB::raw('"VA BTN" as payment_type'), + 'created_at', + 'expired_at', + DB::raw('"failed" as payment_status'), + DB::raw('NULL as invoice_id') + ); + + // 4. Gabungkan (Union) dan Eksekusi + $payments = $paid + ->unionAll($unpaid) + ->unionAll($failed) + ->orderBy('created_at', 'desc') + ->get(); + $view_path = 'frontend.' . get_frontend_settings('theme') . '.student.purchase_history.index'; - return view($view_path, $page_data); + + return view($view_path, compact('payments')); } public function invoice($id) diff --git a/app/Http/Controllers/student/TutorBookingController.php b/app/Http/Controllers/student/TutorBookingController.php index 37cef5c..33732c6 100644 --- a/app/Http/Controllers/student/TutorBookingController.php +++ b/app/Http/Controllers/student/TutorBookingController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\TutorBooking; use App\Models\TutorSchedule; use App\Models\TutorReview; +use App\Models\OfflinePayment; use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Auth; @@ -54,6 +55,20 @@ class TutorBookingController extends Controller return redirect()->back(); } + // check any offline processing data + $processing_payments = OfflinePayment::where([ + 'user_id' => auth()->user()->id, + 'items' => $schedule->id, + 'item_type' => 'tutor_booking', + 'status' => 0, + ]) + ->first(); + + if ($processing_payments) { + Session::flash('warning', get_phrase('Your request is in process.')); + return redirect()->back(); + } + // prepare team package payment data $payment_details = [ 'items' => [ diff --git a/app/Http/Controllers/student/VAPaymentController.php b/app/Http/Controllers/student/VAPaymentController.php new file mode 100644 index 0000000..a66bfbc --- /dev/null +++ b/app/Http/Controllers/student/VAPaymentController.php @@ -0,0 +1,328 @@ +validate([ + 'item_type' => 'required|string', + ]); + + $payment_details = Session::get('payment_details'); + if (!$payment_details) { + return back()->with('error', 'Payment session not found.'); + } + + $item_id_arr = collect($payment_details['items'])->pluck('id')->toArray(); + + // BTN REQUIREMENT + $noTest = str_pad(random_int(0, 99999), 5, '0', STR_PAD_LEFT); + $nama = auth()->user()->name; + $tagihan = $payment_details['payable_amount']; + $expiredAt = Carbon::now()->addHour(); + + DB::beginTransaction(); + + try { + $response = $this->generateDummyVA( + $noTest, + $tagihan, + $nama, + $expiredAt->format('Y-m-d H:i:s') + ); + + $btnResponse = json_decode($response, true); + + Log::info('BTN VA Response', $btnResponse ?? []); + + $vaNumber = $btnResponse['BTNVirtualAccount']; + + if (!$vaNumber) { + throw new \Exception('BTN VA failed: ' . $response); + } + + $vaPayment = VAPayment::create([ + 'user_id' => auth()->id(), + 'item_type' => $request->item_type, + 'items' => json_encode($item_id_arr), + 'tax' => $payment_details['tax'], + 'total_amount' => $tagihan, + 'coupon' => $payment_details['coupon'] ?? null, + 'va_number' => $vaNumber, + 'status' => 0, + 'expired_at' => $expiredAt + ]); + + if ($request->item_type === 'course') { + // Hapus dari cart + CartItem::whereIn('course_id', $item_id_arr) + ->where('user_id', auth()->id()) + ->delete(); + $courses = Course::whereIn('id', $item_id_arr)->get(); + + auth()->user()->notify( + new CoursePurchaseCreated($vaPayment, $courses) + ); + } + + DB::commit(); + + return redirect() + ->route('payment.va.show', $vaPayment->id) + ->with('success', 'Virtual Account berhasil dibuat.'); + + } catch (\Throwable $e) { + DB::rollBack(); + + Log::error('VA Payment Error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return back()->with('error', 'Gagal membuat Virtual Account: ' . $e->getMessage()); + } + } + + private function generateVAFast($notest, $tagihan, $nama, $tgl_terakhirbayar) + { + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => 'https://neosidata.unesa.ac.id/btn_v2/create', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'credential' => '$1$bCE4xLfB$U7t8ux0g4iflFaCpcLqaB.', + 'noid' => $notest, + 'nama' => $nama, + 'tagihan' => $tagihan, + 'flag' => 'F', + 'expired_date' => $tgl_terakhirbayar, + 'deskripsi' => 'Pembayaran Fast Track', + ], + ]); + + $response = curl_exec($curl); + curl_close($curl); + + return $response; + } + + //untuk testing + private function generateDummyVA($notest, $tagihan, $nama, $tgl_terakhirbayar) + { + // Prefix BTN: 9422 + 5 digit random + 8 digit noTest (total 17 digit) + // Format: 9422 + XXXXX + 00000 + noTest (5 digit) + + // Buat angka random 5 digit untuk middle part + $middle = str_pad(random_int(0, 99999), 5, '0', STR_PAD_LEFT); + + // Pad noTest dengan leading zeros jika kurang dari 5 digit + $paddedNoTest = str_pad($notest, 5, '0', STR_PAD_LEFT); + + // Generate VA number (17 digit) + $vaNumber = '9422' . $middle . '0' . $paddedNoTest; // 4 + 5 + 1 + 5 = 15 digit + // Tambah 2 digit random untuk genapin 17 digit + $vaNumber .= str_pad(random_int(0, 99), 2, '0', STR_PAD_LEFT); + + // Format response seperti aslinya + $dummyResponse = [ + 'BTNVirtualAccount' => $vaNumber, + 'status' => '00', + 'message' => 'Success', + 'data' => [ + 'va' => $vaNumber, + 'nama' => $nama, + 'tagihan' => $tagihan, + 'expired_date' => $tgl_terakhirbayar, + 'bank' => 'BTN', + 'deskripsi' => 'Pembayaran Fast Track' + ], + 'timestamp' => now()->toISOString() + ]; + + Log::info('DUMMY VA GENERATED', [ + 'va_number' => $vaNumber, + 'notest' => $notest, + 'padded_notest' => $paddedNoTest, + 'total_digits' => strlen($vaNumber) + ]); + + return json_encode($dummyResponse); + } + + public function show($id) + { + $vaPayment = VAPayment::where('id', $id) + ->where('user_id', auth()->user()->id) + ->firstOrFail(); + + return view('payment.va_show', compact('vaPayment')); + } + + public function checkPaymentApi($id) + { + Log::info('CHECK PAYMENT STARTED'); + + $payment = VAPayment::where('id', $id) + ->where('user_id', auth()->id()) + ->firstOrFail(); + + if ($payment->status == 1) { + return response()->json([ + 'status' => 1, + 'message' => 'Already paid' + ]); + } + + if (now()->greaterThan($payment->expired_at)) { + $payment->update(['status' => 2]); + + return response()->json([ + 'status' => 2, + 'message' => 'Expired' + ]); + } + + try { + $fullVa = $payment->va_number; + $noidcek = substr($fullVa, 5); + + $url = "https://neosidata.unesa.ac.id/api-va-narkoba/{$noidcek}/{$noidcek}"; + + $response = Http::timeout(15) + ->withOptions(['verify' => false]) + ->get($url); + + if (!$response->successful()) { + return response()->json([ + 'status' => 0, + 'message' => 'BTN API error' + ]); + } + + $data = $response->json(); + + $isPaid = is_numeric($data['terbayar'] ?? 0) + && floatval($data['terbayar']) >= floatval($data['tagihan'] ?? 0); + + if (!$isPaid) { + return response()->json([ + 'status' => 0, + 'message' => 'Unpaid' + ]); + } + + DB::beginTransaction(); + + // 1️⃣ Update VA Payment + $payment->update([ + 'status' => 1, + 'paid_at' => now() + ]); + + // 2️⃣ Insert ke payment_history + if ($payment->item_type === 'course') { + $items = json_decode($payment->items); + + foreach ($items as $courseId) { + $course = Course::findOrFail($courseId); + + $amount = $course->discount_flag + ? $course->discounted_price + : $course->price; + + // Coupon + $discount = 0; + if ($payment->coupon) { + $coupon = Coupon::where('code', $payment->coupon)->first(); + if ($coupon) { + $discount = $amount * ($coupon->discount / 100); + } + } + + $finalAmount = $amount - $discount; + $tax = (get_settings('course_selling_tax') / 100) * $finalAmount; + + $history = [ + 'invoice' => Str::random(20), + 'user_id' => $payment->user_id, + 'payment_type' => 'virtual_account', + 'course_id' => $course->id, + 'amount' => $finalAmount, + 'tax' => $tax, + 'coupon' => $payment->coupon, + ]; + + // Revenue split + if (get_course_creator_id($course->id)->role === 'admin') { + $history['admin_revenue'] = $finalAmount; + } else { + $history['instructor_revenue'] = + $finalAmount * (get_settings('instructor_revenue') / 100); + $history['admin_revenue'] = + $finalAmount - $history['instructor_revenue']; + } + + // Payment_history::insert($history); + + // // 3️⃣ Enrollment + // Enrollment::insert([ + // 'user_id' => $payment->user_id, + // 'course_id' => $course->id, + // 'enrollment_type' => 'paid', + // 'entry_date' => time(), + // 'expiry_date' => $course->expiry_period > 0 + // ? strtotime('+' . ($course->expiry_period * 30) . ' days') + // : null, + // 'created_at' => now(), + // 'updated_at' => now(), + // ]); + } + } + + DB::commit(); + + // 4️⃣ Event & Notification + event(new PaymentCompleted($payment)); + + return response()->json([ + 'status' => 1, + 'message' => 'Payment successful' + ]); + + } catch (\Throwable $e) { + DB::rollBack(); + + Log::error('VA CHECK ERROR', [ + 'payment_id' => $payment->id, + 'error' => $e->getMessage() + ]); + + return response()->json([ + 'status' => 0, + 'message' => 'Check failed' + ], 500); + } + } + + +} diff --git a/app/Listeners/SendVirtualAccountPaidNotification.php b/app/Listeners/SendVirtualAccountPaidNotification.php new file mode 100644 index 0000000..d6304ac --- /dev/null +++ b/app/Listeners/SendVirtualAccountPaidNotification.php @@ -0,0 +1,23 @@ +payment; + $user = $payment->user; + + $courseIds = json_decode($payment->items, true); + $courses = Course::whereIn('id', $courseIds)->get(); + + $user->notify( + new CoursePaid($payment, $courses) + ); + } +} diff --git a/app/Models/OfflinePayment.php b/app/Models/OfflinePayment.php new file mode 100644 index 0000000..5c4ffe2 --- /dev/null +++ b/app/Models/OfflinePayment.php @@ -0,0 +1,24 @@ + 'datetime', + 'paid_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + +} diff --git a/app/Models/payment_gateway/Razorpay.php b/app/Models/payment_gateway/Razorpay.php deleted file mode 100644 index 0fdade1..0000000 --- a/app/Models/payment_gateway/Razorpay.php +++ /dev/null @@ -1,110 +0,0 @@ -where('identifier', $identifier)->first(); - $keys = json_decode($payment_gateway->keys, true); - - $public_key = $keys['public_key']; - $secret_key = $keys['secret_key']; - - $curl = curl_init(); - - curl_setopt_array($curl, array( - CURLOPT_URL => 'https://api.razorpay.com/v1/payments/' . $transaction_keys['razorpay_payment_id'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_ENCODING => '', - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => 'GET', - CURLOPT_HTTPHEADER => array( - 'Authorization: Basic ' . base64_encode($public_key . ':' . $keys['secret_key']) - ), - )); - - $response = curl_exec($curl); - - curl_close($curl); - - $response = json_decode($response); - - - if ($response->status == 'captured' || $response->status == 'success') { - return true; - } - } - - public static function payment_create($identifier) - { - $payment_details = session('payment_details'); - $user = DB::table('users')->where('id', auth()->user()->id)->first(); - $model = $payment_details['success_method']['model_name']; - $payment_gateway = DB::table('payment_gateways') - ->where('identifier', $identifier) - ->first(); - - if ($model == 'InstructorPayment') { - $settings = DB::table('users')->where('id', $payment_details['items'][0]['id']) - ->value('paymentkeys'); - $keys = isset($settings) ? json_decode($settings) : null; - - $public_key = $secret_key = ''; - if ($keys) { - $public_key = $keys->razorpay->public_key; - $secret_key = $keys->razorpay->secret_key; - } - - } else { - - $keys = json_decode($payment_gateway->keys, true); - - $public_key = $keys['public_key']; - $secret_key = $keys['secret_key']; - $color = ''; - } - - $receipt_id = Str::random(20); - $api = new Api($public_key, $secret_key); - - $order = $api->order->create(array( - 'receipt' => $receipt_id, - 'amount' => round($payment_details['payable_amount'] * 100, 2), - 'currency' => $payment_gateway->currency, - )); - - $page_data = [ - 'order_id' => $order['id'], - 'razorpay_id' => $public_key, - 'amount' => round($payment_details['payable_amount'] * 100, 2), - - 'name' => $user->name, - 'currency' => $payment_gateway->currency, - 'email' => $user->email, - 'phone' => $user->phone, - 'address' => $user->address, - 'description' => isset($payment_details['custom_field']['description']) ? $payment_details['custom_field']['description'] : '', - ]; - - $data = [ - 'page_data' => $page_data, - 'color' => null, - 'payment_details' => $payment_details, - ]; - return $data; - } -} diff --git a/app/Models/payment_gateway/StripePay.php b/app/Models/payment_gateway/StripePay.php deleted file mode 100644 index 6b77a32..0000000 --- a/app/Models/payment_gateway/StripePay.php +++ /dev/null @@ -1,119 +0,0 @@ -where('identifier', $identifier)->first(); - $keys = json_decode($payment_gateway->keys, true); - - if ($payment_gateway->test_mode == 1) : - $stripeSecretKey = $keys['secret_key']; - else : - $stripeSecretKey = $keys['secret_live_key']; - endif; - - // Check whether stripe checkout session is not empty - $session_id = $transaction_keys['session_id']; - if ($session_id != "") { - - // Set API key - \Stripe\Stripe::setApiKey($stripeSecretKey); - - // Fetch the Checkout Session to display the JSON result on the success page - try { - $checkout_session = \Stripe\Checkout\Session::retrieve($session_id); - } catch (Exception $e) { - $api_error = $e->getMessage(); - } - - if (empty($api_error) && $checkout_session) { - // Retrieve the details of a PaymentIntent - try { - $intent = \Stripe\PaymentIntent::retrieve($checkout_session->payment_intent); - } catch (\Stripe\Exception\ApiErrorException $e) { - $api_error = $e->getMessage(); - } - - if ($intent) { - // Check whether the charge is successful - if ($intent->status == 'succeeded') { - Session::put(['session_id' => $transaction_keys['session_id']]); - return true; - } else { - return false; - } - } else { - return false; - } - } else { - return false; - } - } else { - return false; - } - return false; - } - - public static function payment_create($identifier) - { - - $payment_gateway = DB::table('payment_gateways')->where('identifier', $identifier)->first(); - $payment_details = session('payment_details'); - $keys = json_decode($payment_gateway->keys, true); - - $products_name = ''; - foreach ($payment_details['items'] as $key => $value) : - if ($key == 0) { - $products_name .= $value['title']; - } else { - $products_name .= ', ' . $value['title']; - } - endforeach; - - if ($payment_gateway->test_mode == 1) : - $stripeSecretKey = $keys['secret_key']; - else : - $stripeSecretKey = $keys['secret_live_key']; - endif; - - \Stripe\Stripe::setApiKey($stripeSecretKey); - header('Content-Type: application/json'); - - $YOUR_DOMAIN = 'http://localhost:4242'; - - $checkout_session = \Stripe\Checkout\Session::create([ - 'line_items' => [ - [ - 'price_data' => [ - 'product_data' => [ - 'name' => get_phrase('Purchasing') . ' ' . $products_name, - ], - 'unit_amount' => round($payment_details['payable_amount'] * 100, 2), - 'currency' => $payment_gateway->currency, - ], - 'quantity' => 1, - ], - ], - 'mode' => 'payment', //Checkout has three modes: payment, subscription, or setup. Use payment mode for one-time purchases. Learn more about subscription and setup modes in the docs. - 'success_url' => $payment_details['success_url'] . '/' . $identifier . '?session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => $payment_details['cancel_url'], - ]); - - return $checkout_session->url; - } -} diff --git a/app/Notifications/CoursePaid.php b/app/Notifications/CoursePaid.php new file mode 100644 index 0000000..5b078e6 --- /dev/null +++ b/app/Notifications/CoursePaid.php @@ -0,0 +1,37 @@ +subject(get_phrase('Virtual Account Payment Successful') . ' - ' . config('app.name')) + ->view('vendor.notifications.course_paid', [ + 'user' => $notifiable, + 'payment' => $this->payment, + 'courses' => $this->courses, + ]); + } +} diff --git a/app/Notifications/CoursePurchaseCreated.php b/app/Notifications/CoursePurchaseCreated.php new file mode 100644 index 0000000..31a72e2 --- /dev/null +++ b/app/Notifications/CoursePurchaseCreated.php @@ -0,0 +1,37 @@ +payment = $payment; + $this->courses = $courses; + } + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(get_phrase('Course Purchase Created') . ' - ' . config('app.name')) + ->view('vendor.notifications.course_purchased_created', [ + 'user' => $notifiable, + 'payment' => $this->payment, + 'courses' => $this->courses, + ]); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d65aac..f261d0c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,35 +2,21 @@ namespace App\Providers; -use Illuminate\Auth\Events\Registered; -use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; class EventServiceProvider extends ServiceProvider { - /** - * The event to listener mappings for the application. - * - * @var array> - */ protected $listen = [ - Registered::class => [ - SendEmailVerificationNotification::class, + \App\Events\PaymentCompleted::class => [ + \App\Listeners\SendVirtualAccountPaidNotification::class, ], ]; - /** - * Register any events for your application. - */ public function boot(): void { // } - /** - * Determine if events and listeners should be automatically discovered. - */ public function shouldDiscoverEvents(): bool { return false; diff --git a/public/assets/frontend/default/css/style.css b/public/assets/frontend/default/css/style.css index deacfc9..58e75e7 100644 --- a/public/assets/frontend/default/css/style.css +++ b/public/assets/frontend/default/css/style.css @@ -4035,8 +4035,8 @@ html .ui-button.ui-state-disabled:active { } .ps-sidebar .learn-btn:hover { - background: linear-gradient( to right, #2f57ef 0%, #c664ff 51%, #c664ff 100%); - color: var(--color-white); + background: linear-gradient(45deg, rgba(2, 25, 110, 1) 0%, rgba(2, 25, 110, 1) 13%, rgba(217, 217, 217, 1) 65%, rgba(255, 255, 255, 1) 98%); + color: white !important; border-color: transparent; transition: 0.5s; background-size: 200% auto; diff --git a/public/assets/payment/style/css/style.css b/public/assets/payment/style/css/style.css index 186b27d..3baf6d3 100644 --- a/public/assets/payment/style/css/style.css +++ b/public/assets/payment/style/css/style.css @@ -210,10 +210,6 @@ Common styles END background-color: #797c8b !important; } -.bg-light { - background-color: #dedede !important; -} - .color-success { color: #50cd89 !important; } diff --git a/public/assets/payment/virtualaccount.png b/public/assets/payment/virtualaccount.png new file mode 100644 index 0000000000000000000000000000000000000000..a0c044c6696362105a2c50343de77e31c9068cae GIT binary patch literal 4051 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 FV1nj-%BTPU literal 0 HcmV?d00001 diff --git a/resources/views/admin/admin/permission.blade.php b/resources/views/admin/admin/permission.blade.php index 6d0f600..a633134 100644 --- a/resources/views/admin/admin/permission.blade.php +++ b/resources/views/admin/admin/permission.blade.php @@ -37,6 +37,7 @@ 'admin.newsletter' => get_phrase('Newsletter'), 'admin.subscribed_user' => get_phrase('Newsletter Subscriber'), 'admin.contacts' => get_phrase('Contact User'), + 'admin.offline.payments' => get_phrase('Offline Payment'), 'admin.coupons' => get_phrase('Coupon'), 'admin.blogs' => get_phrase('Blog'), 'admin.pending.blog' => get_phrase('Pending Blog List'), diff --git a/resources/views/admin/navigation.blade.php b/resources/views/admin/navigation.blade.php index 0cdafbe..88420c1 100644 --- a/resources/views/admin/navigation.blade.php +++ b/resources/views/admin/navigation.blade.php @@ -171,11 +171,12 @@ @endif - @if ( has_permission('admin.revenue') || + @if (has_permission('admin.offline.payments') || + has_permission('admin.revenue') || has_permission('admin.instructor.revenue') || has_permission('admin.purchase.history'))