DONE: drop table shoraka financings, and improve UI checking on shoraka order status

This commit is contained in:
2026-10-26 15:04:43 +08:00
parent e3e7694b54
commit 8dd8def358
10 changed files with 95 additions and 368 deletions
@@ -12,6 +12,61 @@ use Throwable;
class ShorakaController extends Controller 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) public function submit(Request $request, ShorakaService $shoraka)
{ {
$validated = $this->validate($request, [ $validated = $this->validate($request, [
@@ -20,6 +75,7 @@ class ShorakaController extends Controller
'order_amount' => 'required|numeric|min:0.01|max:9999999999999.99', 'order_amount' => 'required|numeric|min:0.01|max:9999999999999.99',
'murabaha_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', 'tenor_other' => 'required|integer|min:1',
'teller' => 'nullable|string|max:255',
]); ]);
if (!$shoraka->configured()) { if (!$shoraka->configured()) {
return $this->unconfigured(); return $this->unconfigured();
@@ -51,6 +107,7 @@ class ShorakaController extends Controller
$attributes = [ $attributes = [
'norujukan' => $validated['norujukan'], 'norujukan' => $validated['norujukan'],
'kodcaw' => $request->attributes->get('shoraka_kodcaw'), 'kodcaw' => $request->attributes->get('shoraka_kodcaw'),
'teller' => $validated['teller'] ?? null,
'status' => 'PENDING', 'status' => 'PENDING',
'product_type' => $payload['product_type'], 'product_type' => $payload['product_type'],
'commodity_type' => $payload['commodity_type'], 'commodity_type' => $payload['commodity_type'],
@@ -185,6 +242,7 @@ class ShorakaController extends Controller
return [ return [
'norujukan' => $transaction->norujukan, 'norujukan' => $transaction->norujukan,
'kodcaw' => $transaction->kodcaw, 'kodcaw' => $transaction->kodcaw,
'teller' => $transaction->teller,
'order_id' => $transaction->order_id, 'order_id' => $transaction->order_id,
'status' => $transaction->status, 'status' => $transaction->status,
'certificate_url' => $transaction->certificate_url, 'certificate_url' => $transaction->certificate_url,
@@ -1,168 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\ShorakaFinancing;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
class ShorakaFinancingController extends Controller
{
public function index(Request $request)
{
$this->validate($request, [
'kodcaw' => 'nullable|string|max:30',
'norujukan' => 'nullable|string|max:100',
'state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE',
'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',
]);
try {
$query = ShorakaFinancing::query();
foreach (['kodcaw', 'norujukan', 'state'] 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 (ShorakaFinancing $record) {
return $this->present($record, false);
})->values(),
'total' => $records->total(),
'current_page' => $records->currentPage(),
'per_page' => $records->perPage(),
'last_page' => $records->lastPage(),
];
if ($request->input('with_branches')) {
$branches = ShorakaFinancing::query();
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);
} catch (QueryException $exception) {
return $this->unavailable();
}
}
public function show(string $kodcaw, string $norujukan)
{
try {
$record = $this->find($kodcaw, $norujukan);
} catch (QueryException $exception) {
return $this->unavailable();
}
if (!$record) {
return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404);
}
return response()->json(['data' => $this->present($record)]);
}
public function store(string $kodcaw, string $norujukan, Request $request)
{
$this->validate($request, [
'teller' => 'required|string|max:255',
'action' => 'required|string|max:30',
'context' => 'present|array',
]);
try {
$existing = $this->find($kodcaw, $norujukan);
if ($existing) {
return response()->json(['created' => false, 'data' => $this->present($existing)]);
}
$record = ShorakaFinancing::create([
'kodcaw' => $kodcaw,
'norujukan' => $norujukan,
'teller' => $request->input('teller'),
'action' => $request->input('action'),
'state' => 'WAITING',
'context' => $request->input('context'),
]);
return response()->json(['created' => true, 'data' => $this->present($record)], 201);
} catch (QueryException $exception) {
try {
$existing = $this->find($kodcaw, $norujukan);
} catch (QueryException $again) {
return $this->unavailable();
}
if ($existing) {
return response()->json(['created' => false, 'data' => $this->present($existing)]);
}
return $this->unavailable();
}
}
public function update(string $kodcaw, string $norujukan, Request $request)
{
$this->validate($request, [
'state' => 'required|in:WAITING,PROCESSING,REVIEW,DONE',
'expected_state' => 'nullable|in:WAITING,PROCESSING,REVIEW,DONE',
'context' => 'nullable|array',
]);
try {
$record = $this->find($kodcaw, $norujukan);
if (!$record) {
return response()->json(['message' => 'Rekod pembiayaan tidak dijumpai.'], 404);
}
if ($request->filled('expected_state')) {
$claimed = ShorakaFinancing::where('id', $record->id)
->where('state', $request->input('expected_state'))
->update(['state' => $request->input('state'), 'updated_at' => Carbon::now()]);
return response()->json([
'claimed' => $claimed === 1,
'data' => $this->present($record->fresh()),
]);
}
$fields = ['state' => $request->input('state')];
if ($request->exists('context')) {
$fields['context'] = $request->input('context') ?? [];
}
$record->update($fields);
return response()->json(['claimed' => true, 'data' => $this->present($record->fresh())]);
} catch (QueryException $exception) {
return $this->unavailable();
}
}
private function find(string $kodcaw, string $norujukan): ?ShorakaFinancing
{
return ShorakaFinancing::where('kodcaw', $kodcaw)->where('norujukan', $norujukan)->first();
}
private function present(ShorakaFinancing $record, bool $withContext = true): array
{
$data = [
'id' => $record->id,
'kodcaw' => $record->kodcaw,
'norujukan' => $record->norujukan,
'teller' => $record->teller,
'action' => $record->action,
'state' => $record->state,
'created_at' => optional($record->created_at)->toDateTimeString(),
'updated_at' => optional($record->updated_at)->toDateTimeString(),
];
if ($withContext) {
$data['context'] = $record->context ?? [];
}
return $data;
}
private function unavailable()
{
return response()->json(['message' => 'Rekod pembiayaan Shoraka tidak tersedia.'], 503);
}
}
@@ -12,7 +12,7 @@ class ShorakaGadaiController extends ShorakaController
public function readiness(ShorakaService $shoraka) public function readiness(ShorakaService $shoraka)
{ {
try { try {
\App\ShorakaFinancing::query()->limit(1)->exists(); \App\ShorakaTransaction::query()->limit(1)->exists();
$storeReady = true; $storeReady = true;
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$storeReady = false; $storeReady = false;
@@ -39,7 +39,7 @@ class ShorakaGadaiController extends ShorakaController
]); ]);
} }
public function submitGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) public function submitGadai(string $kodcaw, string $norujukan, Request $request, ShorakaGadaiService $gadaiService, ShorakaService $shoraka)
{ {
$gadai = $gadaiService->gadai($kodcaw, $norujukan); $gadai = $gadaiService->gadai($kodcaw, $norujukan);
$existing = $gadaiService->transaction($kodcaw, $norujukan); $existing = $gadaiService->transaction($kodcaw, $norujukan);
@@ -51,9 +51,14 @@ class ShorakaGadaiController extends ShorakaController
|| $gadai->cetak || $gadai->sttebus) { || $gadai->cetak || $gadai->sttebus) {
abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.'); abort(409, 'Gadaian lama atau telah selesai tidak boleh dihantar sebagai order baharu.');
} }
$request = new Request($gadaiService->payload($gadai)); $payload = $gadaiService->payload($gadai);
$request->attributes->set('shoraka_kodcaw', $kodcaw); $teller = $request->input('teller');
return $this->submit($request, $shoraka); if (is_string($teller) && $teller !== '') {
$payload['teller'] = $teller;
}
$submission = new Request($payload);
$submission->attributes->set('shoraka_kodcaw', $kodcaw);
return $this->submit($submission, $shoraka);
} }
public function refreshGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka) public function refreshGadai(string $kodcaw, string $norujukan, ShorakaGadaiService $gadaiService, ShorakaService $shoraka)
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class ShorakaFinancing extends Model
{
protected $connection = 'mysql7';
protected $table = 'shoraka_financings';
protected $guarded = [];
protected $casts = ['context' => 'array'];
}
+1
View File
@@ -9,6 +9,7 @@ class ShorakaTransaction extends Model
protected $fillable = [ protected $fillable = [
'norujukan', 'norujukan',
'kodcaw', 'kodcaw',
'teller',
'order_id', 'order_id',
'status', 'status',
'product_type', 'product_type',
@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateMasterShorakaFinancingsTable extends Migration
{
public function up()
{
Schema::connection('mysql7')->create('shoraka_financings', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('kodcaw', 20);
$table->string('norujukan');
$table->string('teller');
$table->string('action', 30);
$table->string('state', 30)->default('WAITING');
$table->json('context');
$table->timestamps();
$table->unique(['kodcaw', 'norujukan']);
});
}
public function down()
{
Schema::connection('mysql7')->dropIfExists('shoraka_financings');
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AddTellerToShorakaTransactions extends Migration
{
public function up()
{
Schema::table('shoraka_transactions', function (Blueprint $table) {
$table->string('teller')->nullable();
});
}
public function down()
{
Schema::table('shoraka_transactions', function (Blueprint $table) {
$table->dropColumn('teller');
});
}
}
-147
View File
@@ -1,147 +0,0 @@
# 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.
+1 -4
View File
@@ -120,10 +120,7 @@ $router->group(['prefix'=>'api'], function () use ($router){
$router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']); $router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']);
$router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']); $router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']);
$router->get('shoraka/financings', ['uses' => 'ShorakaFinancingController@index']); $router->get('shoraka/transactions', ['uses' => 'ShorakaController@index']);
$router->get('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@show']);
$router->post('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@store']);
$router->put('shoraka/financings/{kodcaw}/{norujukan}', ['uses' => 'ShorakaFinancingController@update']);
$router->get('shoraka/gadai/{kodcaw}/{norujukan}', ['uses' => 'ShorakaGadaiController@showGadai']); $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}/submit', ['uses' => 'ShorakaGadaiController@submitGadai']);
$router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']); $router->post('shoraka/gadai/{kodcaw}/{norujukan}/refresh', ['uses' => 'ShorakaGadaiController@refreshGadai']);
+2
View File
@@ -28,6 +28,8 @@ class ShorakaIntegrationTest extends TestCase
(new CreateShorakaTransactionsTable())->up(); (new CreateShorakaTransactionsTable())->up();
require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php'; require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php';
(new AddKodcawToShorakaTransactions())->up(); (new AddKodcawToShorakaTransactions())->up();
require_once __DIR__ . '/../database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php';
(new AddTellerToShorakaTransactions())->up();
Http::swap(new Factory()); Http::swap(new Factory());
// Fail any HTTP request that a test did not explicitly stub. // Fail any HTTP request that a test did not explicitly stub.
Log::shouldReceive('error')->zeroOrMoreTimes(); Log::shouldReceive('error')->zeroOrMoreTimes();