Feature/shoraka integration #22

Closed
afrina wants to merge 10 commits from feature/shoraka-integration into main
14 changed files with 1077 additions and 481 deletions
Showing only changes of commit 6c9f2f4f0f - Show all commits
+16
View File
@@ -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)
+14 -28
View File
@@ -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();
+199 -270
View File
@@ -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);
}
}
+73
View File
@@ -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
View File
@@ -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;
}
}
}
+1
View File
@@ -8,6 +8,7 @@ class ShorakaTransaction extends Model
{
protected $fillable = [
'norujukan',
'kodcaw',
'order_id',
'status',
'product_type',
+5 -34
View File
@@ -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());
}
}
}
}
+8 -40
View File
@@ -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');
});
}
}
+147
View File
@@ -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.
+9
View File
@@ -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']);
+124
View File
@@ -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();
}
}
+258
View File
@@ -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();
}
}