Feature/shoraka integration #22
@@ -229,6 +229,9 @@ class GadaiController extends Controller
|
||||
|
||||
|
||||
public function daftarGadaiMarhun($norujukan,Request $request){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) {
|
||||
abort(409, 'Kontrak telah dihantar ke Shoraka dan tidak boleh diubah.');
|
||||
}
|
||||
// dd("ggwp");
|
||||
$master = new MasterServices($request->kodcaw);
|
||||
$response = $master->createMarhunGadai($request->norujukan,$request);
|
||||
@@ -406,6 +409,11 @@ class GadaiController extends Controller
|
||||
}
|
||||
|
||||
public function pembelianKomuditi($kodcaw,$norujukan){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()
|
||||
|| !\App\KomuditiTransaksi::on('mysql7')->where('norujukan', $norujukan)->exists()) {
|
||||
app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan);
|
||||
}
|
||||
|
||||
$cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first();
|
||||
|
||||
$gadai = Gadai::on($cawangan['mysql'])
|
||||
@@ -418,6 +426,10 @@ class GadaiController extends Controller
|
||||
}
|
||||
|
||||
public function cetakSAG($kodcaw,$norujukan,Request $request){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) {
|
||||
app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan);
|
||||
}
|
||||
|
||||
$harini = Carbon::now();
|
||||
|
||||
$cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first();
|
||||
@@ -442,6 +454,10 @@ class GadaiController extends Controller
|
||||
}
|
||||
|
||||
public function serahMarhun($kodcaw,$norujukan){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $norujukan)->exists()) {
|
||||
app(\App\Services\ShorakaGadaiService::class)->assertCompleted($kodcaw, $norujukan);
|
||||
}
|
||||
|
||||
$cawangan = Cawangan::on('mysql7')->where('kodcaw','=',$kodcaw)->first();
|
||||
$gadai = Gadai::on($cawangan['mysql'])
|
||||
->where('norujukan','=',$norujukan)
|
||||
|
||||
@@ -117,6 +117,9 @@ class KomuditiController extends Controller
|
||||
}
|
||||
|
||||
public function updateKomuditi(Request $request){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) {
|
||||
abort(409, 'Transaksi Shoraka memerlukan semakan operasi; stok lama tidak boleh diubah.');
|
||||
}
|
||||
$current = Carbon::now();
|
||||
$current = new Carbon();
|
||||
|
||||
@@ -138,37 +141,20 @@ class KomuditiController extends Controller
|
||||
return response()->json($komuditi_transaksi);
|
||||
}
|
||||
|
||||
public function transaksiKomuditi(Request $request){
|
||||
$current = Carbon::now();
|
||||
$current = new Carbon();
|
||||
|
||||
$komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first();
|
||||
|
||||
$unit_current = $komuditi_current['unit_komuditi'];
|
||||
$update_unit = $unit_current - $request->unit_komuditi;
|
||||
|
||||
$unit_current = round((float) $komuditi_current['unit_komuditi'], 2);
|
||||
$unit_komuditi = round((float) $request->unit_komuditi, 2);
|
||||
$update_unit = round($unit_current - $unit_komuditi, 2);
|
||||
|
||||
$update_komuditi = KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([
|
||||
'unit_komuditi' => $update_unit
|
||||
]);
|
||||
|
||||
$komuditi_transaksi = KomuditiTransaksi::on('mysql7')->create([
|
||||
'unit_komuditi' => $unit_komuditi,
|
||||
'nosijil'=>$komuditi_current['no_sijil'],
|
||||
'norujukan' => $request->norujukan,
|
||||
'kodcaw' => $request->kodcaw ,
|
||||
'teller' => $request->teller ,
|
||||
'masa' => $current->toTimeString(),
|
||||
'tarikh' => $current->toDateString()
|
||||
]);
|
||||
|
||||
return response()->json($komuditi_transaksi);
|
||||
public function transaksiKomuditi(Request $request)
|
||||
{
|
||||
$this->validate($request, ['kodcaw' => 'required|string', 'norujukan' => 'required|string']);
|
||||
return app(ShorakaGadaiController::class)->submitGadai(
|
||||
$request->kodcaw, $request->norujukan,
|
||||
app(\App\Services\ShorakaGadaiService::class),
|
||||
app(\App\Services\ShorakaService::class)
|
||||
);
|
||||
}
|
||||
|
||||
public function batalKomuditi(Request $request){
|
||||
if (\App\ShorakaTransaction::where('norujukan', $request->norujukan)->exists()) {
|
||||
abort(409, 'Transaksi Shoraka memerlukan semakan operasi; stok lama tidak boleh diubah.');
|
||||
}
|
||||
|
||||
$komuditi_current = KomuditiPurchase::on('mysql7')->orderBy('created_at','desc')->first();
|
||||
|
||||
|
||||
@@ -5,299 +5,228 @@ namespace App\Http\Controllers;
|
||||
use App\ShorakaTransaction;
|
||||
use App\Services\ShorakaService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ShorakaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Hantar transaksi pembiayaan pelanggan ke Shoraka.
|
||||
*/
|
||||
public function submit(
|
||||
Request $request,
|
||||
ShorakaService $shoraka
|
||||
) {
|
||||
/*
|
||||
* Validation untuk Lumen 7.
|
||||
*/
|
||||
public function submit(Request $request, ShorakaService $shoraka)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'norujukan' =>
|
||||
'required|string',
|
||||
|
||||
'ownership' =>
|
||||
'required|string',
|
||||
|
||||
'order_amount' =>
|
||||
'required|numeric|min:0.01',
|
||||
|
||||
'murabaha_amount' =>
|
||||
'required|numeric|min:0.01',
|
||||
|
||||
'tenor_other' =>
|
||||
'required|integer|min:1',
|
||||
'norujukan' => 'required|string|max:255',
|
||||
'ownership' => 'required|string|max:255',
|
||||
'order_amount' => 'required|numeric|min:0.01|max:9999999999999.99',
|
||||
'murabaha_amount' => 'required|numeric|min:0.01|max:9999999999999.99',
|
||||
'tenor_other' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
/*
|
||||
* Jangan hubungi Shoraka selagi credentials
|
||||
* belum diterima atau belum dimasukkan.
|
||||
*/
|
||||
if (
|
||||
empty(config('services.shoraka.api_id')) ||
|
||||
empty(config('services.shoraka.secret_key'))
|
||||
) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' =>
|
||||
'API ID dan Secret Key Shoraka belum dikonfigurasi.',
|
||||
], 503);
|
||||
if (!$shoraka->configured()) {
|
||||
return $this->unconfigured();
|
||||
}
|
||||
|
||||
/*
|
||||
* Semak sama ada nombor rujukan yang sama
|
||||
* sudah pernah berjaya dihantar.
|
||||
*/
|
||||
$existing = ShorakaTransaction::where(
|
||||
'norujukan',
|
||||
$validated['norujukan']
|
||||
)->first();
|
||||
|
||||
if (
|
||||
$existing &&
|
||||
!empty($existing->order_id)
|
||||
) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' =>
|
||||
'Transaksi ini telah dihantar ke Shoraka sebelum ini.',
|
||||
'data' => [
|
||||
'norujukan' =>
|
||||
$existing->norujukan,
|
||||
|
||||
'order_id' =>
|
||||
$existing->order_id,
|
||||
|
||||
'status' =>
|
||||
$existing->status,
|
||||
],
|
||||
], 409);
|
||||
$existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first();
|
||||
if ($existing) {
|
||||
return $this->duplicate($existing);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sediakan data mengikut format Shoraka.
|
||||
*/
|
||||
$payload = [
|
||||
'product_type' =>
|
||||
'FINANCING',
|
||||
|
||||
/*
|
||||
* Kod komoditi perlu disahkan
|
||||
* dengan pihak Shoraka.
|
||||
*/
|
||||
'commodity_type' =>
|
||||
'000-COPPER',
|
||||
|
||||
/*
|
||||
* Nama pelanggan.
|
||||
*/
|
||||
'ownership' =>
|
||||
$validated['ownership'],
|
||||
|
||||
/*
|
||||
* Nombor rujukan pembiayaan eRahn.
|
||||
*/
|
||||
'ownership_doc_no' =>
|
||||
$validated['norujukan'],
|
||||
|
||||
/*
|
||||
* Format yang diminta Shoraka:
|
||||
* DD/MM/YYYY
|
||||
*/
|
||||
'value_date' =>
|
||||
Carbon::now()->format('d/m/Y'),
|
||||
|
||||
'order_currency' =>
|
||||
'MYR',
|
||||
|
||||
'order_amount' =>
|
||||
$validated['order_amount'],
|
||||
|
||||
'murabaha_amount' =>
|
||||
$validated['murabaha_amount'],
|
||||
|
||||
/*
|
||||
* Sila sahkan dengan Shoraka sama ada
|
||||
* nilai sebenar ialah OTHER atau OTHERS.
|
||||
*/
|
||||
'tenor' =>
|
||||
'OTHERS',
|
||||
|
||||
'tenor_other' =>
|
||||
$validated['tenor_other'],
|
||||
|
||||
'tenor_other_unit' =>
|
||||
'MONTH',
|
||||
|
||||
'order_type' =>
|
||||
'Buy & Sell',
|
||||
'product_type' => 'FINANCING',
|
||||
'commodity_type' => '000-COPPER',
|
||||
'ownership' => $validated['ownership'],
|
||||
'ownership_doc_no' => $validated['norujukan'],
|
||||
'value_date' => Carbon::now()->format('d/m/Y'),
|
||||
'order_currency' => 'MYR',
|
||||
'order_amount' => number_format((float) $validated['order_amount'], 2, '.', ''),
|
||||
'murabaha_amount' => number_format((float) $validated['murabaha_amount'], 2, '.', ''),
|
||||
// Appendix codes in v1.5: OTHERS and MONTH.
|
||||
'tenor' => 'OTHERS',
|
||||
'tenor_other' => $validated['tenor_other'],
|
||||
'tenor_other_unit' => 'MONTH',
|
||||
'order_type' => 'Buy & Sell',
|
||||
];
|
||||
|
||||
/*
|
||||
* Simpan rekod awal sebelum request dihantar.
|
||||
*/
|
||||
$transaction = ShorakaTransaction::updateOrCreate(
|
||||
[
|
||||
'norujukan' =>
|
||||
$validated['norujukan'],
|
||||
],
|
||||
[
|
||||
'status' =>
|
||||
'PENDING',
|
||||
|
||||
'product_type' =>
|
||||
$payload['product_type'],
|
||||
|
||||
'commodity_type' =>
|
||||
$payload['commodity_type'],
|
||||
|
||||
'ownership_name' =>
|
||||
$payload['ownership'],
|
||||
|
||||
'ownership_doc_no' =>
|
||||
$payload['ownership_doc_no'],
|
||||
|
||||
'order_amount' =>
|
||||
$payload['order_amount'],
|
||||
|
||||
'murabaha_amount' =>
|
||||
$payload['murabaha_amount'],
|
||||
|
||||
'request_payload' =>
|
||||
$payload,
|
||||
|
||||
'error_message' =>
|
||||
null,
|
||||
]
|
||||
);
|
||||
try {
|
||||
// The unique norujukan index is the atomic claim for concurrent submissions.
|
||||
$transaction = ShorakaTransaction::create([
|
||||
'norujukan' => $validated['norujukan'],
|
||||
'kodcaw' => $request->attributes->get('shoraka_kodcaw'),
|
||||
'status' => 'PENDING',
|
||||
'product_type' => $payload['product_type'],
|
||||
'commodity_type' => $payload['commodity_type'],
|
||||
'ownership_name' => $payload['ownership'],
|
||||
'ownership_doc_no' => $payload['ownership_doc_no'],
|
||||
'order_amount' => $payload['order_amount'],
|
||||
'murabaha_amount' => $payload['murabaha_amount'],
|
||||
'request_payload' => $payload,
|
||||
'attempt_count' => 1,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
$existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first();
|
||||
if ($existing) {
|
||||
return $this->duplicate($existing);
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* Tambah bilangan percubaan request.
|
||||
*/
|
||||
$transaction->increment(
|
||||
'attempt_count'
|
||||
);
|
||||
|
||||
/*
|
||||
* Hantar order kepada API Shoraka.
|
||||
*/
|
||||
$response = $shoraka->submitOrder(
|
||||
$payload
|
||||
);
|
||||
|
||||
/*
|
||||
* Shoraka mesti memberikan orderId.
|
||||
*/
|
||||
if (empty($response['orderId'])) {
|
||||
throw new RuntimeException(
|
||||
'Shoraka tidak memberikan orderId.'
|
||||
);
|
||||
}
|
||||
|
||||
$status = isset($response['status'])
|
||||
? $response['status']
|
||||
: 'SUBMITTED';
|
||||
|
||||
/*
|
||||
* Simpan respons Shoraka.
|
||||
*/
|
||||
$transaction->update([
|
||||
'order_id' =>
|
||||
$response['orderId'],
|
||||
|
||||
'status' =>
|
||||
$status,
|
||||
|
||||
'certificate_url' =>
|
||||
isset($response['certificateUrl'])
|
||||
? $response['certificateUrl']
|
||||
: null,
|
||||
|
||||
'response_payload' =>
|
||||
$response,
|
||||
|
||||
'submitted_at' =>
|
||||
Carbon::now(),
|
||||
|
||||
'completed_at' =>
|
||||
$status === 'Completed'
|
||||
? Carbon::now()
|
||||
: null,
|
||||
|
||||
'error_message' =>
|
||||
null,
|
||||
]);
|
||||
$response = $shoraka->submitOrder($payload);
|
||||
$this->saveOrder($transaction, $response);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
'message' =>
|
||||
'Order berjaya dihantar ke Shoraka.',
|
||||
|
||||
'data' => [
|
||||
'norujukan' =>
|
||||
$transaction->norujukan,
|
||||
|
||||
'order_id' =>
|
||||
$transaction->order_id,
|
||||
|
||||
'status' =>
|
||||
$transaction->status,
|
||||
|
||||
'certificate_url' =>
|
||||
$transaction->certificate_url,
|
||||
],
|
||||
], 200);
|
||||
'message' => 'Order berjaya dihantar ke Shoraka.',
|
||||
'data' => $this->orderData($transaction),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
/*
|
||||
* Simpan status gagal.
|
||||
*/
|
||||
// A timeout or invalid response does not prove the remote trade failed.
|
||||
// Block resubmission until this reference has been reconciled with Shoraka.
|
||||
$transaction->update([
|
||||
'status' =>
|
||||
'FAILED',
|
||||
|
||||
'error_message' =>
|
||||
$exception->getMessage(),
|
||||
'status' => $transaction->order_id ? $transaction->status : 'UNKNOWN',
|
||||
'error_message' => 'Submission outcome requires reconciliation with Shoraka.',
|
||||
]);
|
||||
|
||||
/*
|
||||
* Rekodkan ralat tanpa API Secret Key.
|
||||
*/
|
||||
Log::error(
|
||||
'Shoraka order submission failed',
|
||||
[
|
||||
'norujukan' =>
|
||||
$validated['norujukan'],
|
||||
|
||||
'message' =>
|
||||
$exception->getMessage(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
|
||||
'message' =>
|
||||
'Order gagal dihantar ke Shoraka.',
|
||||
|
||||
/*
|
||||
* Paparkan error untuk UAT sahaja.
|
||||
* Buang field ini semasa production.
|
||||
*/
|
||||
'error' =>
|
||||
$exception->getMessage(),
|
||||
], 502);
|
||||
return $this->upstreamError('submit', $transaction, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(string $norujukan, ShorakaService $shoraka)
|
||||
{
|
||||
if (!$shoraka->configured()) {
|
||||
return $this->unconfigured();
|
||||
}
|
||||
$transaction = ShorakaTransaction::where('norujukan', $norujukan)->firstOrFail();
|
||||
if (!$transaction->order_id) {
|
||||
return $this->missingOrderId();
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $shoraka->orderStatus($transaction->order_id);
|
||||
$this->saveOrder($transaction, $response);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $this->orderData($transaction)]);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->upstreamError('status', $transaction, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function certificate(string $norujukan, ShorakaService $shoraka)
|
||||
{
|
||||
if (!$shoraka->configured()) {
|
||||
return $this->unconfigured();
|
||||
}
|
||||
$transaction = ShorakaTransaction::where('norujukan', $norujukan)->firstOrFail();
|
||||
if (!$transaction->order_id) {
|
||||
return $this->missingOrderId();
|
||||
}
|
||||
|
||||
try {
|
||||
$pdf = $shoraka->certificate($transaction->order_id);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="shoraka-' . $transaction->id . '.pdf"',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->upstreamError('certificate', $transaction, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function callback(Request $request, ShorakaService $shoraka)
|
||||
{
|
||||
if (!$shoraka->configured()) {
|
||||
return $this->unconfigured();
|
||||
}
|
||||
$payload = $request->json()->all();
|
||||
if (!$shoraka->validCallback($payload)) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid callback signature.'], 401);
|
||||
}
|
||||
|
||||
// ownershipDocNo and certificateUrl are NOT signed. Never use them to link
|
||||
// a callback to a local record or download a file.
|
||||
$transaction = ShorakaTransaction::where('order_id', $payload['orderId'])->first();
|
||||
if (!$transaction) {
|
||||
return response()->json(['success' => false, 'message' => 'Order not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
// There is no signed callback timestamp. Query the current order so a
|
||||
// delayed/replayed callback cannot directly restore an old status.
|
||||
$response = $shoraka->orderStatus($transaction->order_id);
|
||||
unset($payload['signature']);
|
||||
$this->saveOrder($transaction, $response, ['callback_payload' => $payload]);
|
||||
|
||||
// Exact acknowledgement required by section 6.
|
||||
return response('OK', 200, ['Content-Type' => 'text/plain']);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->upstreamError('callback', $transaction, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
protected function saveOrder(ShorakaTransaction $transaction, array $response, array $extra = []): void
|
||||
{
|
||||
$transaction->update(array_merge([
|
||||
'order_id' => (string) $response['orderId'],
|
||||
'status' => $response['status'],
|
||||
'certificate_url' => $response['certificateUrl'] ?? null,
|
||||
'response_payload' => $response,
|
||||
'submitted_at' => $transaction->submitted_at ?: Carbon::now(),
|
||||
'completed_at' => $response['status'] === 'Completed'
|
||||
? ($transaction->completed_at ?: Carbon::now()) : $transaction->completed_at,
|
||||
'error_message' => null,
|
||||
], $extra));
|
||||
app(\App\Services\ShorakaGadaiService::class)->sync($transaction);
|
||||
}
|
||||
|
||||
protected function orderData(ShorakaTransaction $transaction): array
|
||||
{
|
||||
return [
|
||||
'norujukan' => $transaction->norujukan,
|
||||
'kodcaw' => $transaction->kodcaw,
|
||||
'order_id' => $transaction->order_id,
|
||||
'status' => $transaction->status,
|
||||
'certificate_url' => $transaction->certificate_url,
|
||||
];
|
||||
}
|
||||
|
||||
protected function duplicate(ShorakaTransaction $transaction)
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Transaksi telah direkodkan. Semak status dengan Shoraka sebelum penghantaran semula.',
|
||||
'data' => $this->orderData($transaction),
|
||||
], 409);
|
||||
}
|
||||
|
||||
protected function missingOrderId()
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Order ID belum tersedia. Semakan dengan Shoraka diperlukan.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
protected function unconfigured()
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'API ID dan Secret Key Shoraka belum dikonfigurasi.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
protected function upstreamError(string $operation, ShorakaTransaction $transaction, Throwable $exception)
|
||||
{
|
||||
// Connection exceptions may include request details; log only the class.
|
||||
Log::error('Shoraka request failed', [
|
||||
'operation' => $operation,
|
||||
'transaction_id' => $transaction->id,
|
||||
'exception_type' => get_class($exception),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Permintaan Shoraka gagal. Semak status transaksi sebelum mencuba semula.',
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\KomuditiTransaksi;
|
||||
use App\Services\ShorakaGadaiService;
|
||||
use App\Services\ShorakaService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ShorakaGadaiController extends ShorakaController
|
||||
{
|
||||
public function readiness(ShorakaService $shoraka)
|
||||
{
|
||||
return response()->json(['provider' => 'shoraka', 'ready' => $shoraka->configured()]);
|
||||
}
|
||||
|
||||
public function showGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService)
|
||||
{
|
||||
$gadai = $gadaiService->gadai($kodcaw, $norujukan);
|
||||
$order = $gadaiService->transaction($kodcaw, $norujukan);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'norujukan' => $norujukan, 'kodcaw' => $kodcaw,
|
||||
'order_amount' => $gadai->pinjaman, 'murabaha_amount' => $gadai->hargajualan,
|
||||
'order' => $order ? $this->orderData($order) : null,
|
||||
'details' => $order ? $order->response_payload : null,
|
||||
'can_complete' => $order && $order->status === 'Completed'
|
||||
&& number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount
|
||||
&& number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka)
|
||||
{
|
||||
$gadai = $gadaiService->gadai($kodcaw, $norujukan);
|
||||
$existing = $gadaiService->transaction($kodcaw, $norujukan);
|
||||
if ($existing) {
|
||||
return $this->duplicate($existing);
|
||||
}
|
||||
// Historical commodity purchases must never be traded again.
|
||||
if (KomuditiTransaksi::on('mysql7')->where('norujukan', $norujukan)->exists()
|
||||
|| $gadai->cetak || $gadai->sttebus) {
|
||||
abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.');
|
||||
}
|
||||
$request = new Request($gadaiService->payload($gadai));
|
||||
$request->attributes->set('shoraka_kodcaw', $kodcaw);
|
||||
return $this->submit($request, $shoraka);
|
||||
}
|
||||
|
||||
public function refreshGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka)
|
||||
{
|
||||
$gadaiService->gadai($kodcaw, $norujukan);
|
||||
$gadaiService->transaction($kodcaw, $norujukan);
|
||||
return $this->refresh($norujukan, $shoraka);
|
||||
}
|
||||
|
||||
public function certificateGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka)
|
||||
{
|
||||
$gadaiService->assertCompleted($kodcaw, $norujukan);
|
||||
return $this->certificate($norujukan, $shoraka);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Cawangan;
|
||||
use App\Customer;
|
||||
use App\Gadai;
|
||||
use App\ShorakaTransaction;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ShorakaGadaiService
|
||||
{
|
||||
public function gadai(string $kodcaw, string $norujukan): Gadai
|
||||
{
|
||||
$branch = Cawangan::on('mysql7')->where('kodcaw', $kodcaw)->firstOrFail();
|
||||
return Gadai::on($branch->mysql)->where('norujukan', $norujukan)->where('kodcaw', $kodcaw)->firstOrFail();
|
||||
}
|
||||
|
||||
public function payload(Gadai $gadai): array
|
||||
{
|
||||
$customer = Customer::on('mysql7')->where(function ($query) use ($gadai) {
|
||||
$query->where('kpbaru', $gadai->nokp)->orWhere('kplama', $gadai->nokp);
|
||||
})->firstOrFail();
|
||||
if (!$customer->nama || $gadai->pinjaman <= 0 || $gadai->hargajualan < $gadai->pinjaman) {
|
||||
throw ValidationException::withMessages([
|
||||
'norujukan' => 'Nama pelanggan, pembiayaan dan harga jualan mesti lengkap.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'norujukan' => $gadai->norujukan,
|
||||
'ownership' => $customer->nama,
|
||||
'order_amount' => $gadai->pinjaman,
|
||||
'murabaha_amount' => $gadai->hargajualan,
|
||||
// Both GadaiV1 and GadaiV2 price the contract using upah12.
|
||||
// "tempoh" is elapsed charge time, NOT the contractual tenor.
|
||||
'tenor_other' => 12,
|
||||
];
|
||||
}
|
||||
|
||||
public function transaction(string $kodcaw, string $norujukan)
|
||||
{
|
||||
$order = ShorakaTransaction::where('norujukan', $norujukan)->first();
|
||||
if ($order && (string) $order->kodcaw !== $kodcaw) {
|
||||
abort(409, 'Rekod Shoraka tidak dipadankan dengan cawangan ini.');
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
public function sync(ShorakaTransaction $order): void
|
||||
{
|
||||
if (!$order->kodcaw) {
|
||||
return;
|
||||
}
|
||||
$gadai = $this->gadai($order->kodcaw, $order->norujukan);
|
||||
$sameAmounts = number_format((float) $gadai->pinjaman, 2, '.', '') === $order->order_amount
|
||||
&& number_format((float) $gadai->hargajualan, 2, '.', '') === $order->murabaha_amount;
|
||||
$gadai->getConnection()->table('gadai')->where('norujukan', $order->norujukan)->update([
|
||||
'tagkomuditi' => $order->status === 'Completed' && $sameAmounts ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function assertCompleted(string $kodcaw, string $norujukan): void
|
||||
{
|
||||
$gadai = $this->gadai($kodcaw, $norujukan);
|
||||
$order = $this->transaction($kodcaw, $norujukan);
|
||||
if (!$order || $order->status !== 'Completed'
|
||||
|| number_format((float) $gadai->pinjaman, 2, '.', '') !== $order->order_amount
|
||||
|| number_format((float) $gadai->hargajualan, 2, '.', '') !== $order->murabaha_amount) {
|
||||
abort(409, 'Pembiayaan dan SAG ditahan sehingga transaksi Shoraka selesai dan amaun sepadan.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-109
@@ -8,6 +8,15 @@ use RuntimeException;
|
||||
|
||||
class ShorakaService
|
||||
{
|
||||
const STATUSES = ['Active', 'Completed', 'Cancelled', 'Pending Sell', 'Take Delivery'];
|
||||
|
||||
const CALLBACK_FIELDS = [
|
||||
'orderId', 'status', 'bankName', 'ownershipName', 'commodityType',
|
||||
'unit', 'volume', 'productType', 'valueDate', 'cancelDate', 'orderType',
|
||||
'orderCurrency', 'orderAmount', 'murabahaAmount', 'tenor',
|
||||
'tenorOther', 'tenorOtherUnit',
|
||||
];
|
||||
|
||||
protected $baseUrl;
|
||||
protected $apiId;
|
||||
protected $secretKey;
|
||||
@@ -15,124 +24,143 @@ class ShorakaService
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim(
|
||||
config('services.shoraka.base_url'),
|
||||
'/'
|
||||
);
|
||||
$this->baseUrl = rtrim((string) config('services.shoraka.base_url'), '/');
|
||||
$this->apiId = (string) config('services.shoraka.api_id');
|
||||
$this->secretKey = (string) config('services.shoraka.secret_key');
|
||||
$this->timeout = (int) config('services.shoraka.timeout', 60);
|
||||
}
|
||||
|
||||
$this->apiId =
|
||||
config('services.shoraka.api_id');
|
||||
|
||||
$this->secretKey =
|
||||
config('services.shoraka.secret_key');
|
||||
|
||||
$this->timeout =
|
||||
config('services.shoraka.timeout', 60);
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->apiId !== '' && $this->secretKey !== '';
|
||||
}
|
||||
|
||||
public function submitOrder(array $data): array
|
||||
{
|
||||
$payload = [
|
||||
'api_id' => $this->apiId,
|
||||
'product_type' =>
|
||||
$data['product_type'],
|
||||
'commodity_type' =>
|
||||
$data['commodity_type'],
|
||||
'ownership' =>
|
||||
$data['ownership'],
|
||||
'value_date' =>
|
||||
$data['value_date'],
|
||||
'order_currency' =>
|
||||
$data['order_currency'],
|
||||
'order_amount' =>
|
||||
$this->formatAmount(
|
||||
$data['order_amount']
|
||||
),
|
||||
'murabaha_amount' =>
|
||||
$this->formatAmount(
|
||||
$data['murabaha_amount']
|
||||
),
|
||||
'tenor' =>
|
||||
$data['tenor'],
|
||||
'tenor_other' =>
|
||||
$data['tenor_other'] ?? '',
|
||||
'tenor_other_unit' =>
|
||||
$data['tenor_other_unit'] ?? '',
|
||||
'order_type' =>
|
||||
$data['order_type'],
|
||||
'ownership_doc_no' =>
|
||||
$data['ownership_doc_no'] ?? '',
|
||||
];
|
||||
|
||||
$payload['signature'] =
|
||||
$this->makeSubmitSignature($payload);
|
||||
|
||||
$response = Http::asForm()
|
||||
->acceptJson()
|
||||
->timeout($this->timeout)
|
||||
->post(
|
||||
$this->baseUrl . '/api/submitorder',
|
||||
$payload
|
||||
);
|
||||
|
||||
return $this->getJsonResponse($response);
|
||||
}
|
||||
|
||||
protected function makeSubmitSignature(
|
||||
array $payload
|
||||
): string {
|
||||
$values = [
|
||||
$this->secretKey,
|
||||
$this->apiId,
|
||||
$payload['product_type'],
|
||||
$payload['commodity_type'],
|
||||
$payload['ownership'],
|
||||
$payload['value_date'],
|
||||
$payload['order_currency'],
|
||||
$payload['order_amount'],
|
||||
$payload['murabaha_amount'],
|
||||
$payload['tenor'],
|
||||
$payload['tenor_other'],
|
||||
$payload['tenor_other_unit'],
|
||||
$payload['order_type'],
|
||||
];
|
||||
|
||||
return hash(
|
||||
'sha256',
|
||||
implode(';', $values)
|
||||
);
|
||||
}
|
||||
|
||||
protected function formatAmount($amount): string
|
||||
{
|
||||
return number_format(
|
||||
(float) $amount,
|
||||
2,
|
||||
'.',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
protected function getJsonResponse(
|
||||
Response $response
|
||||
): array {
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'Shoraka request failed. HTTP ' .
|
||||
$response->status() .
|
||||
': ' .
|
||||
$response->body()
|
||||
);
|
||||
$payload = ['api_id' => $this->apiId];
|
||||
foreach ([
|
||||
'product_type', 'commodity_type', 'ownership', 'value_date',
|
||||
'order_currency', 'order_amount', 'murabaha_amount', 'tenor',
|
||||
'tenor_other', 'tenor_other_unit', 'order_type', 'ownership_doc_no',
|
||||
] as $field) {
|
||||
$payload[$field] = $data[$field] ?? '';
|
||||
}
|
||||
foreach (['order_amount', 'murabaha_amount'] as $field) {
|
||||
$payload[$field] = number_format((float) $payload[$field], 2, '.', '');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
// ownership_doc_no is deliberately excluded by the v1.5 signature contract.
|
||||
$signed = $payload;
|
||||
unset($signed['api_id'], $signed['ownership_doc_no']);
|
||||
$payload['signature'] = $this->signature(array_values($signed));
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException(
|
||||
'Shoraka returned an invalid response.'
|
||||
);
|
||||
return $this->orderResponse($this->request()->post(
|
||||
$this->baseUrl . '/api/submitorder', $payload
|
||||
));
|
||||
}
|
||||
|
||||
public function orderStatus(string $orderId): array
|
||||
{
|
||||
return $this->orderResponse($this->request()->post(
|
||||
$this->baseUrl . '/api/orderstatus', $this->orderPayload($orderId)
|
||||
), $orderId);
|
||||
}
|
||||
|
||||
public function certificate(string $orderId): string
|
||||
{
|
||||
// Section 5 specifies POST, despite GET in the document history.
|
||||
$response = $this->request()->accept('application/pdf')->post(
|
||||
$this->baseUrl . '/api/certificate', $this->orderPayload($orderId)
|
||||
);
|
||||
$this->checkResponse($response);
|
||||
$type = strtolower(trim(explode(';', $response->header('Content-Type'))[0]));
|
||||
if ($type !== 'application/pdf' || substr($response->body(), 0, 5) !== '%PDF-') {
|
||||
throw new RuntimeException('Shoraka returned an invalid PDF certificate.');
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
public function validCallback(array $payload): bool
|
||||
{
|
||||
if (!$this->configured()
|
||||
|| !isset($payload['apiId'], $payload['signature'])
|
||||
|| !is_string($payload['apiId'])
|
||||
|| !hash_equals($this->apiId, $payload['apiId'])
|
||||
|| !is_string($payload['signature'])
|
||||
|| !preg_match('/\A[0-9a-fA-F]{64}\z/', $payload['signature'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach (self::CALLBACK_FIELDS as $field) {
|
||||
// Preserve the original strings: decimal formatting affects the hash.
|
||||
if (!array_key_exists($field, $payload) || !is_string($payload[$field])) {
|
||||
return false;
|
||||
}
|
||||
$values[] = $payload[$field];
|
||||
}
|
||||
|
||||
return $payload['orderId'] !== ''
|
||||
&& in_array($payload['status'], self::STATUSES, true)
|
||||
&& hash_equals($this->signature($values), strtolower($payload['signature']));
|
||||
}
|
||||
|
||||
protected function signature(array $values): string
|
||||
{
|
||||
return hash('sha256', implode(';', array_merge(
|
||||
[$this->secretKey, $this->apiId], $values
|
||||
)));
|
||||
}
|
||||
|
||||
protected function orderPayload(string $orderId): array
|
||||
{
|
||||
if ($orderId === '') {
|
||||
throw new RuntimeException('Shoraka order ID is required.');
|
||||
}
|
||||
|
||||
return [
|
||||
'api_id' => $this->apiId,
|
||||
'order_id' => $orderId,
|
||||
'signature' => $this->signature([$orderId]),
|
||||
];
|
||||
}
|
||||
|
||||
protected function request()
|
||||
{
|
||||
if (!$this->configured()) {
|
||||
throw new RuntimeException('Shoraka credentials are not configured.');
|
||||
}
|
||||
|
||||
// Never forward signed requests through redirects or retry a trade automatically.
|
||||
return Http::asForm()->acceptJson()->timeout($this->timeout)
|
||||
->withOptions(['allow_redirects' => false]);
|
||||
}
|
||||
|
||||
protected function checkResponse(Response $response): void
|
||||
{
|
||||
if (!$response->successful()) {
|
||||
// Do not expose upstream bodies which may contain credentials or customer data.
|
||||
throw new RuntimeException('Shoraka request failed. HTTP ' . $response->status());
|
||||
}
|
||||
}
|
||||
|
||||
protected function orderResponse(Response $response, string $expectedId = null): array
|
||||
{
|
||||
$this->checkResponse($response);
|
||||
$data = $response->json();
|
||||
if (!is_array($data)
|
||||
|| !isset($data['orderId'], $data['status'])
|
||||
|| !(is_string($data['orderId']) || is_int($data['orderId']))
|
||||
|| (string) $data['orderId'] === ''
|
||||
|| !in_array($data['status'], self::STATUSES, true)
|
||||
|| ($expectedId !== null && (string) $data['orderId'] !== $expectedId)) {
|
||||
throw new RuntimeException('Shoraka returned an invalid order response.');
|
||||
}
|
||||
if (isset($data['certificateUrl']) && !is_string($data['certificateUrl'])) {
|
||||
throw new RuntimeException('Shoraka returned an invalid certificate URL.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class ShorakaTransaction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'norujukan',
|
||||
'kodcaw',
|
||||
'order_id',
|
||||
'status',
|
||||
'product_type',
|
||||
|
||||
@@ -11,8 +11,6 @@ use Carbon\Carbon;
|
||||
use App\Gadai;
|
||||
use App\HargaEmas;
|
||||
use App\InOutMar;
|
||||
use App\KomuditiPurchase;
|
||||
use App\KomuditiTransaksi;
|
||||
use App\Marhun;
|
||||
use App\Moratorium;
|
||||
use App\Palsu;
|
||||
@@ -809,19 +807,18 @@ class Penebusan
|
||||
$array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller','bakipinjaman','bakiupah']);
|
||||
$array_trans = $request->only(['nopelanggan', 'noic','jenisAT','pinjaman','pinjamanbaru','marhun','teller','jenistebus']);
|
||||
$array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']);
|
||||
$array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']);
|
||||
|
||||
try{
|
||||
DB::connection($cawangan['mysql'])->beginTransaction();
|
||||
$this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus);
|
||||
$this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
$newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
|
||||
DB::connection('mysql7')->beginTransaction();
|
||||
$this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base);
|
||||
$this->createTransactionBase($norujukan,$kodcaw,$array_trans_base);
|
||||
$array_response = [
|
||||
'result' => 'success',
|
||||
'data' => 1
|
||||
'data' => 1,
|
||||
'norujukan_baru' => $newReference
|
||||
];
|
||||
|
||||
DB::connection($cawangan['mysql'])->commit();
|
||||
@@ -1077,37 +1074,11 @@ class Penebusan
|
||||
't_update' => $current->toDateString(),
|
||||
));
|
||||
|
||||
return $new_sag_number;
|
||||
}catch(\Exception $e){
|
||||
throw new \Exception("Error in Transaction: " . $e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function createTransactionBase($norujukan,$kodcaw,$array_trans_base){
|
||||
$current = Carbon::now();
|
||||
|
||||
$komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first();
|
||||
|
||||
$unit_current = isset($komuditi_current['unit_komuditi']) ? $komuditi_current['unit_komuditi']: 0;
|
||||
|
||||
try{
|
||||
$update_unit = $unit_current - $array_trans_base['pinjamanbaru'];
|
||||
|
||||
KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([
|
||||
'unit_komuditi' => $update_unit
|
||||
]);
|
||||
|
||||
KomuditiTransaksi::on('mysql7')->create([
|
||||
'unit_komuditi' => $update_unit,
|
||||
'nosijil'=>$komuditi_current['no_sijil'],
|
||||
'norujukan' => $norujukan,
|
||||
'kodcaw' => $kodcaw,
|
||||
'teller' => $array_trans_base['teller'],
|
||||
'masa' => $current->toTimeString(),
|
||||
'tarikh' => $current->toDateString()
|
||||
]);
|
||||
}catch(\Exception $e){
|
||||
throw new \Exception("Error in TransactionBase: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ use Carbon\Carbon;
|
||||
use App\Gadai;
|
||||
use App\HargaEmas;
|
||||
use App\InOutMar;
|
||||
use App\KomuditiPurchase;
|
||||
use App\KomuditiTransaksi;
|
||||
use App\Marhun;
|
||||
use App\FailoverTransaction;
|
||||
use App\FailoverTransactionFailed;
|
||||
@@ -984,20 +982,19 @@ class Penebusan
|
||||
$array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller']);
|
||||
$array_trans = $request->only(['nopelanggan', 'noic','jenisAT','pinjaman','pinjamanbaru','marhun','teller','jenistebus']);
|
||||
$array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']);
|
||||
$array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']);
|
||||
|
||||
try{
|
||||
|
||||
DB::connection($cawangan['mysql'])->beginTransaction();
|
||||
$this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus);
|
||||
$this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
$newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
|
||||
DB::connection('mysql7')->beginTransaction();
|
||||
$this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base);
|
||||
$this->createTransactionBase($norujukan,$kodcaw,$array_trans_base);
|
||||
$array_response = [
|
||||
'result' => 'success',
|
||||
'data' => 1
|
||||
'data' => 1,
|
||||
'norujukan_baru' => $newReference
|
||||
];
|
||||
|
||||
DB::connection($cawangan['mysql'])->commit();
|
||||
@@ -1044,19 +1041,18 @@ class Penebusan
|
||||
$array_tebus = $request->only(['jeniskeuntungan', 'nopelanggan','jenistebus','addpinjaman','nowakil','marhun','teller']);
|
||||
$array_trans = $request->only(['nopelanggan', 'noic','pinjaman','pinjamanbaru','marhun','teller','jenistebus']);
|
||||
$array_tebus_base = $request->only(['totalbayaran', 'bakiupah','jeniskeuntungan','bakipinjaman','jenistebus','teller','nowakil','duitpelanggan']);
|
||||
$array_trans_base = $request->only(['pinjaman','teller','pinjamanbaru']);
|
||||
|
||||
try{
|
||||
DB::connection($cawangan['mysql'])->beginTransaction();
|
||||
$this->saveTebus($kodcaw,$norujukan,$gadai,$cawangan,$array_tebus);
|
||||
$this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
$newReference = $this->createTransaction($cawangan,$kodcaw,$norujukan,$array_trans);
|
||||
DB::connection('mysql7')->beginTransaction();
|
||||
$this->saveTebusBase($kodcaw,$norujukan,$gadai,$array_tebus_base);
|
||||
$this->createTransactionBase($norujukan,$kodcaw,$array_trans_base);
|
||||
|
||||
$array_response = [
|
||||
'result' => 'success',
|
||||
'data' => 1
|
||||
'data' => 1,
|
||||
'norujukan_baru' => $newReference
|
||||
];
|
||||
|
||||
DB::connection($cawangan['mysql'])->commit();
|
||||
@@ -1356,39 +1352,11 @@ class Penebusan
|
||||
't_update' => $current->toDateString(),
|
||||
));
|
||||
|
||||
return $new_sag_number;
|
||||
}catch(\Exception $e){
|
||||
throw new \Exception("Error in Transaction: " . $e->getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function createTransactionBase($norujukan,$kodcaw,$array_trans_base){
|
||||
$current = Carbon::now();
|
||||
|
||||
$komuditi_current = KomuditiPurchase::on('mysql7')->where('date_purchase','=',$current->toDateString())->orderBy('created_at','desc')->first();
|
||||
|
||||
$unit_current = isset($komuditi_current['unit_komuditi']) ? $komuditi_current['unit_komuditi']: 0;
|
||||
|
||||
try{
|
||||
$update_unit = $unit_current - $array_trans_base['pinjamanbaru'];
|
||||
|
||||
KomuditiPurchase::on('mysql7')->where('id','=',$komuditi_current['id'])->update([
|
||||
'unit_komuditi' => $update_unit
|
||||
]);
|
||||
|
||||
KomuditiTransaksi::on('mysql7')->create([
|
||||
'unit_komuditi' => $update_unit,
|
||||
'nosijil'=>$komuditi_current['no_sijil'],
|
||||
'norujukan' => $norujukan,
|
||||
'kodcaw' => $kodcaw,
|
||||
'teller' => $array_trans_base['teller'],
|
||||
'masa' => $current->toTimeString(),
|
||||
'tarikh' => $current->toDateString()
|
||||
]);
|
||||
}catch(\Exception $e){
|
||||
throw new \Exception("Error in TransactionBase: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddKodcawToShorakaTransactions extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('shoraka_transactions', function (Blueprint $table) {
|
||||
$table->string('kodcaw', 20)->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('shoraka_transactions', function (Blueprint $table) {
|
||||
$table->dropColumn('kodcaw');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
# Shoraka STP integration and kaunter workflow
|
||||
|
||||
Based on Shoraka Al-Amin STP API v1.5.0 (9 January 2026).
|
||||
|
||||
## Agreed behavior
|
||||
|
||||
New financing uses Shoraka instead of deducting internal commodity stock.
|
||||
Cash/online financing completion and SAG printing wait for a Completed trade.
|
||||
Existing historical commodity records retain their legacy reporting/printing path.
|
||||
|
||||
The counter saves financing details, records a resumable local workflow, and
|
||||
submits the trade. It redirects the teller to **Pembiayaan Shoraka**.
|
||||
The teller uses **Semak Status**, then **Selesaikan Pembiayaan** when eligible.
|
||||
This second step performs the cash update or registers the online payment for
|
||||
the existing PC approval flow. It does not itself transfer funds through a bank.
|
||||
|
||||
Ordinary financing, the two older refinancing paths, additional financing and
|
||||
auto-tawarruq use this workflow. API refinancing returns norujukan_baru to ensure
|
||||
the commodity trade belongs to the newly created financing, not the redeemed one.
|
||||
Existing refinancing registration/settlement database writes still occur before
|
||||
the commodity request; the counter cash completion and SAG are held afterwards.
|
||||
|
||||
## Configuration and migrations
|
||||
|
||||
API environment settings:
|
||||
|
||||
- SHORAKA_BASE_URL: default https://uat2.shorakagroup.com
|
||||
- SHORAKA_API_ID and SHORAKA_SECRET_KEY: supplied by Shoraka
|
||||
- SHORAKA_TIMEOUT: seconds, default 60
|
||||
|
||||
The counter calls its existing API_URL; Shoraka credentials are kept in the API.
|
||||
Register the public HTTPS /api/shoraka/callback URL and arrange server IP
|
||||
whitelisting with Shoraka. Confirm the configured environment before UAT/live use.
|
||||
|
||||
Deploy both repositories together and apply the following migrations using the
|
||||
normal deployment process, before enabling counter traffic. They are not run
|
||||
against application databases by the automated tests.
|
||||
|
||||
In api-kopkb (configured default database; existing migration may already be applied):
|
||||
|
||||
~~~sh
|
||||
php artisan migrate --path=database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php
|
||||
php artisan migrate --path=database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php
|
||||
~~~
|
||||
|
||||
In kaunter-kopkb (counter application database):
|
||||
|
||||
~~~sh
|
||||
php artisan migrate --path=database/migrations/2026_10_05_120000_create_shoraka_financings_table.php
|
||||
~~~
|
||||
|
||||
Clear/rebuild any deployment route, config and view caches through the usual
|
||||
deployment process. Do not mix a newly deployed API with an old counter build.
|
||||
|
||||
## Data mapping
|
||||
|
||||
| Shoraka field | Source |
|
||||
| --- | --- |
|
||||
| ownership | Central customer.nama matched using gadai.nokp |
|
||||
| ownership_doc_no | gadai.norujukan |
|
||||
| order_amount | Saved gadai.pinjaman |
|
||||
| murabaha_amount | Saved gadai.hargajualan |
|
||||
| tenor / tenor_other / unit | OTHERS / 12 / MONTH |
|
||||
| product / commodity / currency / order | FINANCING / 000-COPPER / MYR / Buy & Sell |
|
||||
|
||||
Both current financing engines calculate the contract using upah12 and a
|
||||
12-month contract end date. The elapsed-charge fields tempoh and tempohbayar
|
||||
are not contractual tenor fields.
|
||||
|
||||
The backend derives amounts and customer identity; it ignores client-supplied
|
||||
amounts on the branch submission endpoint. Each Shoraka order stores kodcaw.
|
||||
A completed response/callback synchronizes gadai.tagkomuditi to 1 only when saved
|
||||
financing amounts still match. Contract edits after submission are rejected.
|
||||
Payment completion also verifies the staged payout amount.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | /api/shoraka/readiness | Configuration readiness (not a live connectivity probe) |
|
||||
| GET | /api/shoraka/gadai/{kodcaw}/{norujukan} | Local order, financing amounts and completion eligibility |
|
||||
| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/submit | Submit from saved financing data |
|
||||
| POST | /api/shoraka/gadai/{kodcaw}/{norujukan}/refresh | Query Shoraka and synchronize branch status |
|
||||
| GET | /api/shoraka/gadai/{kodcaw}/{norujukan}/certificate | Download the fixed-endpoint PDF after Completed |
|
||||
| POST | /api/shoraka/callback | Verify signature, refresh and acknowledge |
|
||||
| POST | /api/shoraka/orders | Existing standalone submission contract |
|
||||
| POST | /api/shoraka/orders/{norujukan}/refresh | Existing standalone refresh |
|
||||
| GET | /api/shoraka/orders/{norujukan}/certificate | Existing standalone PDF download |
|
||||
|
||||
The old PUT /api/komuditi/transaksi endpoint now delegates to branch Shoraka
|
||||
submission. New loans do not consume KomuditiPurchase stock. Historical stock
|
||||
administration remains available; old stock update/cancel endpoints reject
|
||||
references already associated with Shoraka.
|
||||
|
||||
Counter /shoraka pages require session authentication, an authorized role and
|
||||
the user's branch. Only the original teller can submit/finish a staged financing.
|
||||
POST forms use CSRF protection. API endpoints retain the repository's existing
|
||||
network/access-control arrangement; restrict API access accordingly.
|
||||
|
||||
## Completion and recovery
|
||||
|
||||
shoraka_financings is the counter's durable completion record:
|
||||
|
||||
- WAITING: awaiting trade completion or teller completion.
|
||||
- PROCESSING: one request has atomically claimed payment completion.
|
||||
- DONE: completion returned successfully; saved request context is cleared.
|
||||
- REVIEW: completion raised an error; do not retry payment automatically.
|
||||
|
||||
Only one WAITING request can claim completion. Repeated requests for DONE do
|
||||
not repeat cash/online processing. A worker crash while PROCESSING or an
|
||||
ambiguous response during payment requires operational reconciliation.
|
||||
|
||||
Shoraka PENDING/UNKNOWN/legacy FAILED entries block repeated submissions.
|
||||
A timeout does not prove a remote trade failed. If an orderId is known, refresh.
|
||||
Otherwise reconcile the reference with Shoraka and link/correct the record
|
||||
through an authorized operational process before resubmission.
|
||||
|
||||
A failure between remote financing registration and local workflow creation
|
||||
also requires reconciliation using the newly created reference. Do not repeat
|
||||
the original refinancing operation, which may already have settled the old loan.
|
||||
The guide provides neither a reference-based lookup nor an idempotency key.
|
||||
|
||||
Historical print paths remain for records with no Shoraka order/workflow.
|
||||
For Shoraka financing, SAG metadata uses certificateDetails1, commodityType,
|
||||
unit and volume from its own trade. The separate PDF is fetched from the signed
|
||||
certificate endpoint. Missing certificate metadata blocks SAG printing.
|
||||
|
||||
## Protocol choices and UAT checks
|
||||
|
||||
- Appendix specifies OTHERS although prose says OTHER; use OTHERS.
|
||||
- Section 5 specifies POST /api/certificate although history says GET; use POST.
|
||||
- Order lookup/PDF signature: SECRET_KEY;API_ID;order_id.
|
||||
- Callback camelCase strings are hashed in documented order without decimal normalization.
|
||||
- ownershipDocNo and certificate fields are unsigned. Match a known orderId and
|
||||
re-query current status rather than trusting those callback fields.
|
||||
- Respond with the exact plain text OK only after status persistence/synchronization.
|
||||
- Unknown callback orderId returns 404. An early callback needs redelivery or
|
||||
subsequent refresh; the guide does not specify a retry policy.
|
||||
- Confirm protocol inconsistencies, callback delivery and full certificate metadata in UAT.
|
||||
|
||||
## Tests
|
||||
|
||||
API: tests/ShorakaGadaiTest.php (includes the existing protocol regression tests).
|
||||
Counter: tests/Feature/ShorakaFinancingTest.php.
|
||||
|
||||
Use PHPUnit with PDO SQLite enabled. Tests use in-memory databases and fake
|
||||
HTTP responses. They do not run live trades, payouts or branch migrations.
|
||||
@@ -119,8 +119,17 @@ $router->group(['prefix'=>'api'], function () use ($router){
|
||||
$router->get('gadai/list/rekod/old',['uses'=>'GadaiController@rekodGadaian']);
|
||||
$router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']);
|
||||
|
||||
$router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']);
|
||||
$router->get('shoraka/gadai/{kodcaw}/{norujukan}', ['uses' => 'ShorakaGadaiController@showGadai']);
|
||||
$router->post('shoraka/gadai/{kodcaw}/{norujukan}/submit', ['uses' => 'ShorakaGadaiController@submitGadai']);
|
||||
$router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']);
|
||||
$router->get('shoraka/gadai/{kodcaw}/{norujukan}/certificate', ['uses' => 'ShorakaGadaiController@certificateGadai']);
|
||||
|
||||
// Shoraka Commodity API
|
||||
$router->post('shoraka/orders',['uses' => 'ShorakaController@submit']);
|
||||
$router->post('shoraka/orders/{norujukan}/refresh', ['uses' => 'ShorakaController@refresh']);
|
||||
$router->get('shoraka/orders/{norujukan}/certificate', ['uses' => 'ShorakaController@certificate']);
|
||||
$router->post('shoraka/callback', ['uses' => 'ShorakaController@callback']);
|
||||
|
||||
$router->get('check/serah/gadai',['uses'=>'GadaiController@checkSerahGadaian']);
|
||||
$router->get('check/serah/tebus',['uses'=>'TebusController@checkSerahTebus']);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ShorakaIntegrationTest.php';
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\ShorakaTransaction;
|
||||
|
||||
class ShorakaGadaiTest extends ShorakaIntegrationTest
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
foreach (['mysql7', 'branch_test'] as $connection) {
|
||||
$this->app['config']->set('database.connections.' . $connection, [
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
]);
|
||||
DB::purge($connection);
|
||||
}
|
||||
Schema::connection('mysql7')->create('arrahn_master_db', function (Blueprint $table) {
|
||||
$table->string('kodcaw'); $table->string('mysql');
|
||||
});
|
||||
Schema::connection('mysql7')->create('customer', function (Blueprint $table) {
|
||||
$table->string('kpbaru'); $table->string('kplama')->nullable(); $table->string('nama');
|
||||
});
|
||||
Schema::connection('mysql7')->create('komuditi_transaksi', function (Blueprint $table) {
|
||||
$table->string('norujukan');
|
||||
});
|
||||
Schema::connection('branch_test')->create('gadai', function (Blueprint $table) {
|
||||
$table->string('norujukan'); $table->string('nokp'); $table->string('kodcaw');
|
||||
$table->decimal('pinjaman', 15, 2); $table->decimal('hargajualan', 15, 2);
|
||||
$table->integer('tagserah')->default(0);
|
||||
$table->integer('tagkomuditi')->default(0); $table->integer('cetak')->default(0);
|
||||
$table->string('sttebus')->default('');
|
||||
});
|
||||
DB::connection('mysql7')->table('arrahn_master_db')->insert([
|
||||
['kodcaw' => 'KB2', 'mysql' => 'branch_test'],
|
||||
['kodcaw' => 'KB3', 'mysql' => 'branch_test'],
|
||||
]);
|
||||
DB::connection('mysql7')->table('customer')->insert(['kpbaru' => 'IC-1', 'nama' => 'Nama Database']);
|
||||
DB::connection('branch_test')->table('gadai')->insert([
|
||||
'norujukan' => 'SAG-001', 'kodcaw' => 'KB2', 'nokp' => 'IC-1',
|
||||
'pinjaman' => 1000, 'hargajualan' => 1200,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testBranchSubmissionUsesDatabaseAmountsAndSynchronizesTag(): void
|
||||
{
|
||||
$this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']);
|
||||
$this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit', [
|
||||
'order_amount' => 1, 'ownership' => 'Tampered',
|
||||
])->seeStatusCode(200);
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['order_amount'] === '1000.00'
|
||||
&& $request['murabaha_amount'] === '1200.00'
|
||||
&& $request['ownership'] === 'Nama Database'
|
||||
&& (int) $request['tenor_other'] === 12;
|
||||
});
|
||||
$this->assertSame('KB2', ShorakaTransaction::first()->kodcaw);
|
||||
$this->assertEquals(1, DB::connection('branch_test')->table('gadai')->value('tagkomuditi'));
|
||||
$this->json('GET', '/api/shoraka/gadai/KB2/SAG-001')->seeJson(['can_complete' => true]);
|
||||
}
|
||||
|
||||
public function testPendingOrderKeepsTagUnsetAndBlocksSag(): void
|
||||
{
|
||||
$this->fakeResponse(['orderId' => '9999', 'status' => 'Active']);
|
||||
$this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit')->seeStatusCode(200);
|
||||
$this->assertEquals(0, DB::connection('branch_test')->table('gadai')->value('tagkomuditi'));
|
||||
$this->json('PUT', '/api/gadai/norujukan/cetak/KB2/SAG-001')->seeStatusCode(409);
|
||||
}
|
||||
|
||||
public function testLegacyPurchaseCannotBeSubmittedAgain(): void
|
||||
{
|
||||
DB::connection('mysql7')->table('komuditi_transaksi')->insert(['norujukan' => 'SAG-001']);
|
||||
Http::fake();
|
||||
$this->json('POST', '/api/shoraka/gadai/KB2/SAG-001/submit')->seeStatusCode(409);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testChangedFinancingCannotBeCompleted(): void
|
||||
{
|
||||
$record = $this->transaction('Completed');
|
||||
$record->update(['kodcaw' => 'KB2']);
|
||||
DB::connection('branch_test')->table('gadai')->update(['pinjaman' => 900]);
|
||||
$this->json('GET', '/api/shoraka/gadai/KB2/SAG-001')->seeJson(['can_complete' => false]);
|
||||
$this->json('GET', '/api/shoraka/gadai/KB2/SAG-001/certificate')->seeStatusCode(409);
|
||||
}
|
||||
|
||||
public function testDifferentBranchCannotReadOrder(): void
|
||||
{
|
||||
$record = $this->transaction('Completed');
|
||||
$record->update(['kodcaw' => 'KB2']);
|
||||
$this->json('GET', '/api/shoraka/gadai/KB3/SAG-001')->seeStatusCode(404);
|
||||
}
|
||||
|
||||
public function testHistoricalFinancingWithoutCommodityLedgerCanStillBeHandedOver(): void
|
||||
{
|
||||
Http::fake();
|
||||
$this->json('PUT', '/api/gadai/norujukan/serah/KB2/SAG-001')->seeStatusCode(200);
|
||||
$this->assertEquals(1, DB::connection('branch_test')->table('gadai')->value('tagserah'));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testSubmittedContractCannotBeRegisteredAgain(): void
|
||||
{
|
||||
$this->transaction('Completed')->update(['kodcaw' => 'KB2']);
|
||||
Http::fake();
|
||||
$this->json('PUT', '/api/gadai/norujukan/KB2/SAG-001', [
|
||||
'kodcaw' => 'KB2', 'norujukan' => 'SAG-001',
|
||||
])->seeStatusCode(409);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testShorakaOrderCannotUpdateOrCancelLegacyStock(): void
|
||||
{
|
||||
$this->transaction('Completed')->update(['kodcaw' => 'KB2']);
|
||||
Http::fake();
|
||||
$this->json('PUT', '/api/komuditi/update', ['norujukan' => 'SAG-001'])->seeStatusCode(409);
|
||||
$this->json('PUT', '/api/komuditi/batal', ['norujukan' => 'SAG-001'])->seeStatusCode(409);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ShorakaService;
|
||||
use App\ShorakaTransaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\Factory;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShorakaIntegrationTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->app['config']->set('services.shoraka', [
|
||||
'base_url' => 'https://shoraka.test',
|
||||
'api_id' => 'API_ID',
|
||||
'secret_key' => 'SECRET_KEY',
|
||||
'timeout' => 5,
|
||||
]);
|
||||
// All database writes stay in memory; never run migrations on branch databases.
|
||||
$this->app['config']->set('database.default', 'shoraka_test');
|
||||
$this->app['config']->set('database.connections.shoraka_test', [
|
||||
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
|
||||
]);
|
||||
DB::purge('shoraka_test');
|
||||
require_once __DIR__ . '/../database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php';
|
||||
(new CreateShorakaTransactionsTable())->up();
|
||||
require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php';
|
||||
(new AddKodcawToShorakaTransactions())->up();
|
||||
Http::swap(new Factory());
|
||||
// Fail any HTTP request that a test did not explicitly stub.
|
||||
Log::shouldReceive('error')->zeroOrMoreTimes();
|
||||
}
|
||||
|
||||
protected function fakeResponse($body, array $headers = [], int $status = 200): void
|
||||
{
|
||||
Http::fake(function ($request) use ($body, $headers, $status) {
|
||||
$this->assertStringStartsWith('https://shoraka.test/api/', $request->url());
|
||||
return Http::response($body, $status, $headers);
|
||||
});
|
||||
}
|
||||
|
||||
protected function submission(): array
|
||||
{
|
||||
return [
|
||||
'norujukan' => 'SAG-001', 'ownership' => 'Abu Samad',
|
||||
'order_amount' => '1000.00', 'murabaha_amount' => '1200.00',
|
||||
'tenor_other' => 6,
|
||||
];
|
||||
}
|
||||
|
||||
protected function transaction(string $status = 'Active', $orderId = '9999'): ShorakaTransaction
|
||||
{
|
||||
return ShorakaTransaction::create([
|
||||
'norujukan' => 'SAG-001', 'order_id' => $orderId, 'status' => $status,
|
||||
'product_type' => 'FINANCING', 'commodity_type' => '000-COPPER',
|
||||
'ownership_name' => 'Abu Samad', 'order_amount' => '1000.00',
|
||||
'murabaha_amount' => '1200.00', 'attempt_count' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function callbackPayload(): array
|
||||
{
|
||||
// Published signature example, v1.5 page 14.
|
||||
return [
|
||||
'apiId' => 'API_ID', 'orderId' => '9999', 'status' => 'Completed',
|
||||
'bankName' => 'Test Bank Bhd', 'ownershipName' => 'Abu Samad',
|
||||
'commodityType' => '000-COPPER', 'unit' => 'Tonne', 'volume' => '245.678901',
|
||||
'productType' => 'FINANCING', 'valueDate' => '08/12/2025', 'cancelDate' => '',
|
||||
'orderType' => 'Buy & Sell', 'orderCurrency' => 'MYR',
|
||||
'orderAmount' => '1000000.00', 'murabahaAmount' => '1000000.00',
|
||||
'tenor' => 'O/N', 'tenorOther' => '', 'tenorOtherUnit' => '',
|
||||
'signature' => 'bbc6f59e9c64aea9e30f3c03d30f63e8fc37898d2699c0cdd41e416a518aa122',
|
||||
];
|
||||
}
|
||||
|
||||
public function testSubmitSignatureMatchesPublishedExample(): void
|
||||
{
|
||||
$this->fakeResponse(['orderId' => '12345', 'status' => 'Completed']);
|
||||
(new ShorakaService())->submitOrder([
|
||||
'product_type' => 'DEPOSIT', 'commodity_type' => '000-COPPER',
|
||||
'ownership' => 'Abu Samad', 'value_date' => '10/11/2022',
|
||||
'order_currency' => 'MYR', 'order_amount' => 1000000,
|
||||
'murabaha_amount' => 1000000, 'tenor' => 'O/N',
|
||||
'order_type' => 'Buy & Sell', 'ownership_doc_no' => 'SAG-001',
|
||||
]);
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://shoraka.test/api/submitorder'
|
||||
&& $request->hasHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
&& $request['order_amount'] === '1000000.00'
|
||||
&& $request['ownership_doc_no'] === 'SAG-001'
|
||||
&& $request['signature'] === '43970ac7a5c3867133af0dd92fd5eef9a7b415ebdb8b0d1025d4686bc25693bd';
|
||||
});
|
||||
}
|
||||
|
||||
public function testStatusUsesPublishedSignatureAndChecksOrderIdentity(): void
|
||||
{
|
||||
$this->fakeResponse(['orderId' => '12345', 'status' => 'Active']);
|
||||
(new ShorakaService())->orderStatus('12345');
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://shoraka.test/api/orderstatus'
|
||||
&& $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc';
|
||||
});
|
||||
$this->expectException(RuntimeException::class);
|
||||
(new ShorakaService())->orderStatus('another-order');
|
||||
}
|
||||
|
||||
public function testCallbackSignatureMatchesPublishedExampleAndRejectsTampering(): void
|
||||
{
|
||||
$service = new ShorakaService();
|
||||
$payload = $this->callbackPayload();
|
||||
$this->assertTrue($service->validCallback($payload));
|
||||
foreach (ShorakaService::CALLBACK_FIELDS as $field) {
|
||||
$changed = $payload;
|
||||
$changed[$field] .= 'x';
|
||||
$this->assertFalse($service->validCallback($changed), $field);
|
||||
$missing = $payload;
|
||||
unset($missing[$field]);
|
||||
$this->assertFalse($service->validCallback($missing), $field);
|
||||
}
|
||||
$payload['apiId'] = 'wrong';
|
||||
$this->assertFalse($service->validCallback($payload));
|
||||
}
|
||||
|
||||
public function testSubmitPersistsOrderAndRejectsDuplicate(): void
|
||||
{
|
||||
$this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']);
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(200);
|
||||
$record = ShorakaTransaction::first();
|
||||
$this->assertSame('9999', $record->order_id);
|
||||
$this->assertSame('Completed', $record->status);
|
||||
$this->assertNotNull($record->completed_at);
|
||||
$this->assertArrayNotHasKey('signature', $record->request_payload);
|
||||
$this->assertSame('OTHERS', $record->request_payload['tenor']);
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function testAmbiguousFailureBlocksResubmissionAndHidesUpstreamBody(): void
|
||||
{
|
||||
$this->fakeResponse('SECRET_KEY confidential', [], 500);
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(502);
|
||||
$this->assertStringNotContainsString('SECRET_KEY', $this->response->getContent());
|
||||
$record = ShorakaTransaction::first();
|
||||
$this->assertSame('UNKNOWN', $record->status);
|
||||
$this->assertStringNotContainsString('SECRET_KEY', $record->error_message);
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function testPendingSubmissionCannotBeSubmittedAgain(): void
|
||||
{
|
||||
$this->transaction('PENDING', null);
|
||||
Http::fake();
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testRefreshUpdatesStatus(): void
|
||||
{
|
||||
$record = $this->transaction();
|
||||
$this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']);
|
||||
$this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(200);
|
||||
$this->assertSame('Completed', $record->fresh()->status);
|
||||
$this->assertNotNull($record->fresh()->completed_at);
|
||||
}
|
||||
|
||||
public function testCallbackAcknowledgesAndUsesCurrentStatusNotUnsignedFields(): void
|
||||
{
|
||||
$record = $this->transaction();
|
||||
$payload = $this->callbackPayload();
|
||||
$payload['certificateUrl'] = 'https://untrusted.test/file';
|
||||
$payload['ownershipDocNo'] = 'other-reference';
|
||||
$this->fakeResponse([
|
||||
'orderId' => '9999', 'status' => 'Cancelled',
|
||||
'certificateUrl' => 'https://shoraka.test/certificate/9999',
|
||||
]);
|
||||
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200);
|
||||
$this->assertSame('OK', $this->response->getContent());
|
||||
$this->assertSame('Cancelled', $record->fresh()->status);
|
||||
$this->assertSame('https://shoraka.test/certificate/9999', $record->fresh()->certificate_url);
|
||||
$this->assertArrayNotHasKey('signature', $record->fresh()->callback_payload);
|
||||
// Repeated delivery is acknowledged without creating another order.
|
||||
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200);
|
||||
$this->assertSame(1, ShorakaTransaction::count());
|
||||
}
|
||||
|
||||
public function testInvalidCallbackDoesNotMutateOrCallUpstream(): void
|
||||
{
|
||||
$record = $this->transaction();
|
||||
Http::fake();
|
||||
$payload = $this->callbackPayload();
|
||||
$payload['status'] = 'Cancelled';
|
||||
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(401);
|
||||
$this->assertSame('Active', $record->fresh()->status);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testUnknownCallbackIsNotAcknowledged(): void
|
||||
{
|
||||
Http::fake();
|
||||
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(404);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testFailedCallbackRefreshPreservesRecord(): void
|
||||
{
|
||||
$record = $this->transaction();
|
||||
$this->fakeResponse('unavailable', [], 503);
|
||||
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(502);
|
||||
$this->assertSame('Active', $record->fresh()->status);
|
||||
$this->assertNull($record->fresh()->callback_payload);
|
||||
}
|
||||
|
||||
public function testCertificateReturnsPdfFromFixedEndpoint(): void
|
||||
{
|
||||
$this->transaction('Completed', '12345');
|
||||
$pdf = "%PDF-1.4\nexample";
|
||||
$this->fakeResponse($pdf, ['Content-Type' => 'application/pdf']);
|
||||
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(200);
|
||||
$this->assertSame($pdf, $this->response->getContent());
|
||||
$this->assertSame('application/pdf', $this->response->headers->get('Content-Type'));
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://shoraka.test/api/certificate'
|
||||
&& $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc';
|
||||
});
|
||||
}
|
||||
|
||||
public function testCertificateRejectsNonPdf(): void
|
||||
{
|
||||
$this->transaction();
|
||||
$this->fakeResponse(['error' => 'not ready']);
|
||||
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(502);
|
||||
}
|
||||
|
||||
public function testMissingCredentialsPreventRequests(): void
|
||||
{
|
||||
$this->app['config']->set('services.shoraka.secret_key', '');
|
||||
Http::fake();
|
||||
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(503);
|
||||
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(503);
|
||||
$this->assertSame(0, ShorakaTransaction::count());
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function testMissingOrderIdCannotBeRefreshedOrDownloaded(): void
|
||||
{
|
||||
$this->transaction('UNKNOWN', null);
|
||||
Http::fake();
|
||||
$this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(409);
|
||||
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(409);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user