292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?php
|
|
|
|
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 Throwable;
|
|
|
|
class ShorakaController extends Controller
|
|
{
|
|
public function index(Request $request)
|
|
{
|
|
$this->validate($request, [
|
|
'kodcaw' => 'nullable|string|max:30',
|
|
'norujukan' => 'nullable|string|max:100',
|
|
'status' => 'nullable|string|max:30',
|
|
'date_from' => 'nullable|date_format:Y-m-d',
|
|
'date_to' => 'nullable|date_format:Y-m-d',
|
|
'per_page' => 'nullable|integer|min:1|max:100',
|
|
'page' => 'nullable|integer|min:1',
|
|
'branch_scope' => 'nullable|string|max:30',
|
|
]);
|
|
|
|
$query = ShorakaTransaction::query();
|
|
foreach (['kodcaw', 'norujukan', 'status'] as $field) {
|
|
if ($request->filled($field)) {
|
|
$query->where($field, $request->input($field));
|
|
}
|
|
}
|
|
if ($request->filled('date_from')) {
|
|
$query->where('created_at', '>=', $request->input('date_from') . ' 00:00:00');
|
|
}
|
|
if ($request->filled('date_to')) {
|
|
$query->where('created_at', '<', Carbon::parse($request->input('date_to'))->addDay()->format('Y-m-d') . ' 00:00:00');
|
|
}
|
|
$records = $query->orderByDesc('created_at')->orderByDesc('id')
|
|
->paginate(min(100, max(1, (int) $request->input('per_page', 25))));
|
|
$payload = [
|
|
'data' => $records->getCollection()->map(function (ShorakaTransaction $record) {
|
|
return [
|
|
'id' => $record->id,
|
|
'kodcaw' => $record->kodcaw,
|
|
'norujukan' => $record->norujukan,
|
|
'teller' => $record->teller,
|
|
'status' => $record->status,
|
|
'order_id' => $record->order_id,
|
|
'created_at' => optional($record->created_at)->toDateTimeString(),
|
|
'updated_at' => optional($record->updated_at)->toDateTimeString(),
|
|
];
|
|
})->values(),
|
|
'total' => $records->total(),
|
|
'current_page' => $records->currentPage(),
|
|
'per_page' => $records->perPage(),
|
|
'last_page' => $records->lastPage(),
|
|
];
|
|
if ($request->input('with_branches')) {
|
|
$branches = ShorakaTransaction::query()->whereNotNull('kodcaw');
|
|
if ($request->filled('branch_scope')) {
|
|
$branches->where('kodcaw', $request->input('branch_scope'));
|
|
}
|
|
$payload['branches'] = $branches->distinct()->orderBy('kodcaw')->pluck('kodcaw')->values();
|
|
}
|
|
return response()->json($payload);
|
|
}
|
|
|
|
public function submit(Request $request, ShorakaService $shoraka)
|
|
{
|
|
$validated = $this->validate($request, [
|
|
'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',
|
|
'teller' => 'nullable|string|max:255',
|
|
]);
|
|
if (!$shoraka->configured()) {
|
|
return $this->unconfigured();
|
|
}
|
|
|
|
$existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first();
|
|
if ($existing) {
|
|
return $this->duplicate($existing);
|
|
}
|
|
|
|
$payload = [
|
|
'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',
|
|
];
|
|
|
|
try {
|
|
// The unique norujukan index is the atomic claim for concurrent submissions.
|
|
$attributes = [
|
|
'norujukan' => $validated['norujukan'],
|
|
'kodcaw' => $request->attributes->get('shoraka_kodcaw'),
|
|
'teller' => $validated['teller'] ?? null,
|
|
'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,
|
|
];
|
|
$transaction = ShorakaTransaction::create($attributes);
|
|
} catch (QueryException $exception) {
|
|
$existing = ShorakaTransaction::where('norujukan', $validated['norujukan'])->first();
|
|
if ($existing) {
|
|
return $this->duplicate($existing);
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
try {
|
|
$response = $shoraka->submitOrder($payload);
|
|
$this->saveOrder($transaction, $response);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Order berjaya dihantar ke Shoraka.',
|
|
'data' => $this->orderData($transaction),
|
|
]);
|
|
} catch (Throwable $exception) {
|
|
// 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' => $transaction->order_id ? $transaction->status : 'UNKNOWN',
|
|
'error_message' => 'Submission outcome requires reconciliation with Shoraka.',
|
|
]);
|
|
|
|
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,
|
|
'teller' => $transaction->teller,
|
|
'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);
|
|
}
|
|
}
|