From 8dd8def3588ab8cf55951a90f4acc68d35b05b7d Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 26 Oct 2026 15:04:43 +0800 Subject: [PATCH] DONE: drop table shoraka financings, and improve UI checking on shoraka order status --- app/Http/Controllers/ShorakaController.php | 58 ++++++ .../ShorakaFinancingController.php | 168 ------------------ .../Controllers/ShorakaGadaiController.php | 15 +- app/ShorakaFinancing.php | 16 -- app/ShorakaTransaction.php | 1 + ...create_master_shoraka_financings_table.php | 28 --- ...000_add_teller_to_shoraka_transactions.php | 23 +++ docs/shoraka-integration.md | 147 --------------- routes/web.php | 5 +- tests/ShorakaIntegrationTest.php | 2 + 10 files changed, 95 insertions(+), 368 deletions(-) delete mode 100644 app/Http/Controllers/ShorakaFinancingController.php delete mode 100644 app/ShorakaFinancing.php delete mode 100644 database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php create mode 100644 database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php delete mode 100644 docs/shoraka-integration.md diff --git a/app/Http/Controllers/ShorakaController.php b/app/Http/Controllers/ShorakaController.php index 4f5e6e0..ef58838 100644 --- a/app/Http/Controllers/ShorakaController.php +++ b/app/Http/Controllers/ShorakaController.php @@ -12,6 +12,61 @@ 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, [ @@ -20,6 +75,7 @@ class ShorakaController extends Controller '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(); @@ -51,6 +107,7 @@ class ShorakaController extends Controller $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'], @@ -185,6 +242,7 @@ class ShorakaController extends Controller return [ 'norujukan' => $transaction->norujukan, 'kodcaw' => $transaction->kodcaw, + 'teller' => $transaction->teller, 'order_id' => $transaction->order_id, 'status' => $transaction->status, 'certificate_url' => $transaction->certificate_url, diff --git a/app/Http/Controllers/ShorakaFinancingController.php b/app/Http/Controllers/ShorakaFinancingController.php deleted file mode 100644 index 32b63c7..0000000 --- a/app/Http/Controllers/ShorakaFinancingController.php +++ /dev/null @@ -1,168 +0,0 @@ -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); - } -} diff --git a/app/Http/Controllers/ShorakaGadaiController.php b/app/Http/Controllers/ShorakaGadaiController.php index ab9ac86..4fb772a 100644 --- a/app/Http/Controllers/ShorakaGadaiController.php +++ b/app/Http/Controllers/ShorakaGadaiController.php @@ -12,7 +12,7 @@ class ShorakaGadaiController extends ShorakaController public function readiness(ShorakaService $shoraka) { try { - \App\ShorakaFinancing::query()->limit(1)->exists(); + \App\ShorakaTransaction::query()->limit(1)->exists(); $storeReady = true; } catch (\Throwable $exception) { $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); $existing = $gadaiService->transaction($kodcaw, $norujukan); @@ -51,9 +51,14 @@ class ShorakaGadaiController extends ShorakaController || $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); + $payload = $gadaiService->payload($gadai); + $teller = $request->input('teller'); + 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) diff --git a/app/ShorakaFinancing.php b/app/ShorakaFinancing.php deleted file mode 100644 index bf919f7..0000000 --- a/app/ShorakaFinancing.php +++ /dev/null @@ -1,16 +0,0 @@ - 'array']; -} diff --git a/app/ShorakaTransaction.php b/app/ShorakaTransaction.php index 160a1ca..bb0938a 100644 --- a/app/ShorakaTransaction.php +++ b/app/ShorakaTransaction.php @@ -9,6 +9,7 @@ class ShorakaTransaction extends Model protected $fillable = [ 'norujukan', 'kodcaw', + 'teller', 'order_id', 'status', 'product_type', diff --git a/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php b/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php deleted file mode 100644 index 06419e7..0000000 --- a/database/migrations/2026_10_19_120000_create_master_shoraka_financings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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'); - } -} diff --git a/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php b/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php new file mode 100644 index 0000000..7e6f512 --- /dev/null +++ b/database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php @@ -0,0 +1,23 @@ +string('teller')->nullable(); + }); + } + + public function down() + { + Schema::table('shoraka_transactions', function (Blueprint $table) { + $table->dropColumn('teller'); + }); + } +} diff --git a/docs/shoraka-integration.md b/docs/shoraka-integration.md deleted file mode 100644 index f3cb510..0000000 --- a/docs/shoraka-integration.md +++ /dev/null @@ -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. diff --git a/routes/web.php b/routes/web.php index 3312168..db59f2c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,10 +120,7 @@ $router->group(['prefix'=>'api'], function () use ($router){ $router->get('gadai/online/search/detail',['uses'=>'GadaiController@searchGadaiOnline']); $router->get('shoraka/readiness', ['uses' => 'ShorakaGadaiController@readiness']); - $router->get('shoraka/financings', ['uses' => 'ShorakaFinancingController@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/transactions', ['uses' => 'ShorakaController@index']); $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']); diff --git a/tests/ShorakaIntegrationTest.php b/tests/ShorakaIntegrationTest.php index d5d1e34..30d36cb 100644 --- a/tests/ShorakaIntegrationTest.php +++ b/tests/ShorakaIntegrationTest.php @@ -28,6 +28,8 @@ class ShorakaIntegrationTest extends TestCase (new CreateShorakaTransactionsTable())->up(); require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php'; (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()); // Fail any HTTP request that a test did not explicitly stub. Log::shouldReceive('error')->zeroOrMoreTimes();