feat(shoraka): integrate transactions with Shoraka API

This commit is contained in:
nurafrinaalimi16
2026-10-08 12:30:02 +08:00
parent 5d208f7a79
commit 6c9f2f4f0f
14 changed files with 1077 additions and 481 deletions
+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);
}
}