Feature/whatsapp blast #16

Merged
ismailmasseran merged 5 commits from feature/whatsapp-blast into main 2026-09-24 11:27:48 +08:00
8 changed files with 373 additions and 4 deletions
Showing only changes of commit 9629a075d7 - Show all commits
+7 -1
View File
@@ -249,10 +249,16 @@ WABOT_INSTANCES_ID=63F40BD120DB4
# WABOT_INSTANCES_ID=64892FD7440A6
WABOT_TOKEN_KEYS=cbee7e95cf3ffbfa0531d5fddfd44f87
# Meta WhatsApp Cloud (test number). Paste token locally; do not commit it.
WHATSAPP_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=1323449077519027
WHATSAPP_API_VERSION=v25.0
WHATSAPP_TEMPLATE=hello_world
WHATSAPP_TEMPLATE_LANG=en_US
ONEWAYSMS_API=http://gateway.onewaysms.com.my:10001/api.aspx
ONEWAYSMS_AUTH_USER=APIUK330HDAFO
ONEWAYSMS_AUTH_PASS=APIUK330HDAFOUK330
ADMIN_PHONE_NUMBER=60178916936
ADMIN_PHONE_NUMBER=60193450165
ACTIVE_JOB_SERVER=false
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Services\Whatsapp\WhatsappCloudClient;
use Illuminate\Console\Command;
class WhatsappTestSend extends Command
{
protected $signature = 'whatsapp:test {--to= : Recipient MSISDN, default ADMIN_PHONE_NUMBER}';
protected $description = 'Send the configured WhatsApp Cloud template to a test number';
public function handle()
{
$client = new WhatsappCloudClient();
if (!$client->isConfigured()) {
$this->error('Set WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID in .env first.');
return 1;
}
$to = $client->normalizeMsisdn($this->option('to') ?: env('ADMIN_PHONE_NUMBER'));
if (!$to) {
$this->error('Pass --to=60193450165 or set ADMIN_PHONE_NUMBER.');
return 1;
}
$template = config('whatsapp.template', 'hello_world');
$lang = config('whatsapp.template_lang', 'en_US');
$bodyParams = [];
if (strtolower($template) !== 'hello_world') {
$bodyParams = [
'Tampoi',
'TEST-SAG',
'100.00',
'Maybank ****1234',
date('d/m/Y'),
'TEST-REF',
];
}
$this->info("Sending {$template} ({$lang}) to {$to} …");
$response = $client->sendTemplate($to, $template, $lang, $bodyParams);
if ($response->successful()) {
$this->info('OK ' . $response->status() . ' ' . $response->body());
return 0;
}
$this->error('FAIL ' . $response->status() . ' ' . $response->body());
return 1;
}
}
+1
View File
@@ -24,6 +24,7 @@ class Kernel extends ConsoleKernel
Commands\ClearCachesAll::class,
Commands\RestartSupervisor::class,
Commands\InsertSempornaData::class,
Commands\WhatsappTestSend::class,
//Commands\ImportCsv::class,
];
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Jobs;
use App\BlastWasap;
use App\Customer;
use App\Services\Whatsapp\WhatsappCloudClient;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class WhatsappPembiayaanOnlinePaid extends Job
{
public $kodcaw;
public $cawName;
public $nokp;
public $norujukan;
public $pinjaman;
public $namaBank;
public $noakaun;
public $tarikhBayaran;
public $noRujukanBayaran;
public function __construct(array $data)
{
$this->kodcaw = $data['kodcaw'] ?? null;
$this->cawName = $data['caw_name'] ?? null;
$this->nokp = $data['nokp'] ?? null;
$this->norujukan = $data['norujukan'] ?? null;
$this->pinjaman = $data['pinjaman'] ?? null;
$this->namaBank = $data['nama_bank'] ?? null;
$this->noakaun = $data['noakaun'] ?? null;
$this->tarikhBayaran = $data['tarikh_bayaran'] ?? null;
$this->noRujukanBayaran = $data['no_rujukan_bayaran'] ?? null;
}
public function handle()
{
$client = new WhatsappCloudClient();
$now = Carbon::now();
if (!$client->isConfigured()) {
Log::warning('WhatsApp Cloud skipped: token or phone_number_id missing', [
'norujukan' => $this->norujukan,
]);
return;
}
$customer = Customer::on('mysql7')
->where(function ($q) {
$q->where('kpbaru', $this->nokp)->orWhere('kplama', $this->nokp);
})
->first();
$storedPhone = $customer ? ($customer->telefon ?: $customer->telefon2) : null;
$to = $client->normalizeMsisdn($storedPhone);
if (env('APP_ENV') !== 'production') {
$admin = $client->normalizeMsisdn(env('ADMIN_PHONE_NUMBER'));
if (!$admin) {
Log::warning('WhatsApp Cloud skipped: ADMIN_PHONE_NUMBER required when not production', [
'norujukan' => $this->norujukan,
]);
$this->logBlast($now, $storedPhone, 'skipped: ADMIN_PHONE_NUMBER empty');
return;
}
$to = $admin;
}
if (!$to) {
Log::warning('WhatsApp Cloud skipped: no phone', [
'nokp' => $this->nokp,
'norujukan' => $this->norujukan,
]);
$this->logBlast($now, $storedPhone, 'skipped: no phone');
return;
}
$template = config('whatsapp.template', 'hello_world');
$lang = config('whatsapp.template_lang', 'en_US');
$bodyParams = $this->isHelloWorld($template) ? [] : $this->bodyParams();
try {
$response = $client->sendTemplate($to, $template, $lang, $bodyParams);
$this->logBlast($now, $to, $response->body());
} catch (\Throwable $e) {
Log::error('WhatsappPembiayaanOnlinePaid failed: ' . $e->getMessage(), [
'norujukan' => $this->norujukan,
]);
$this->logBlast($now, $to, 'exception: ' . $e->getMessage());
}
}
protected function isHelloWorld($template)
{
return strtolower((string) $template) === 'hello_world';
}
/**
* Matches pembiayaan_online_paid body: cawangan, SAG, amaun, akaun, tarikh, rujukan.
*
* @return array<int,string>
*/
protected function bodyParams()
{
$akaun = trim((string) $this->namaBank);
$tail = $this->accountTail($this->noakaun);
if ($tail !== '') {
$akaun = trim($akaun . ' ' . $tail);
}
$tarikh = $this->tarikhBayaran
? Carbon::parse($this->tarikhBayaran)->format('d/m/Y')
: Carbon::now()->format('d/m/Y');
return [
$this->cawName ?: $this->kodcaw,
$this->norujukan ?: '-',
number_format((float) $this->pinjaman, 2, '.', ''),
$akaun !== '' ? $akaun : '-',
$tarikh,
$this->noRujukanBayaran ?: '-',
];
}
protected function accountTail($noakaun)
{
$digits = preg_replace('/\D+/', '', (string) $noakaun);
if ($digits === '') {
return '';
}
return '****' . substr($digits, -4);
}
protected function logBlast($now, $telefon, $status)
{
try {
$log = new BlastWasap();
$log->kodcaw = $this->kodcaw;
$log->nokp = $this->nokp;
$log->notelefon = $telefon;
$log->norujukan = $this->norujukan;
$log->status = is_string($status) ? $status : json_encode($status);
$log->created_at = $now;
$log->updated_at = $now;
$log->save();
} catch (\Throwable $e) {
Log::error('log_blast_wasap save failed: ' . $e->getMessage());
}
}
}
@@ -9,7 +9,9 @@ use App\Cawangan;
use App\Customer;
use App\Feature;
use App\Gadai;
use App\Jobs\WhatsappPembiayaanOnlinePaid;
use App\PembiayaanOnline;
use App\Services\Whatsapp\WhatsappCloudClient;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -1613,8 +1615,8 @@ class PembiayaanOnlineService
}
/**
* PC marks PAID line(s) as tallied / blast done (blast_sent=1).
* Actual WhatsApp/SMS dispatch can be wired later.
* PC marks PAID line(s) as tallied / blast done (blast_sent=1),
* then queues WhatsApp Cloud template to each customer (or ADMIN_PHONE_NUMBER off-prod).
*
* @param array|int|string $ids
* @return array{ok:bool,status:int,payload:mixed}
@@ -1643,7 +1645,7 @@ class PembiayaanOnlineService
$updated = 0;
try {
return DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $ids, $blastBy, $now, &$updated) {
$result = DB::connection($mysql)->transaction(function () use ($mysql, $kodcaw, $ids, $blastBy, $now, &$updated) {
$lines = PembiayaanOnline::on($mysql)
->where('kodcaw', $kodcaw)
->whereIn('id', $ids)
@@ -1697,6 +1699,50 @@ class PembiayaanOnlineService
'payload' => ['message' => 'Gagal tanda blast/tally'],
];
}
if (!empty($result['ok'])) {
$this->queuePaidWhatsapp($kodcaw, $result['payload']['data'] ?? []);
}
return $result;
}
/**
* Dispatch Cloud API jobs after tally commit. Failures are logged; tally stays PAID/blast_sent.
*
* @param \Illuminate\Support\Collection|array $lines
*/
protected function queuePaidWhatsapp($kodcaw, $lines)
{
$client = new WhatsappCloudClient();
if (!$client->isConfigured()) {
Log::info('WhatsApp Cloud not configured; tally saved without send', ['kodcaw' => $kodcaw]);
return;
}
$cawangan = Cawangan::on('mysql7')->where('kodcaw', $kodcaw)->first();
$cawName = $cawangan ? $cawangan->nama_cawangan : $kodcaw;
foreach ($lines as $line) {
try {
dispatch(new WhatsappPembiayaanOnlinePaid([
'kodcaw' => $kodcaw,
'caw_name' => $cawName,
'nokp' => $line->nokp ?? null,
'norujukan' => $line->norujukan ?? null,
'pinjaman' => $line->pinjaman ?? null,
'nama_bank' => $line->nama_bank ?? null,
'noakaun' => $line->noakaun ?? null,
'tarikh_bayaran' => $line->tarikh_bayaran ?? null,
'no_rujukan_bayaran' => $line->no_rujukan_bayaran ?? null,
]));
} catch (\Throwable $e) {
Log::error('queuePaidWhatsapp dispatch failed: ' . $e->getMessage(), [
'kodcaw' => $kodcaw,
'id' => $line->id ?? null,
]);
}
}
}
/**
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Whatsapp;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WhatsappCloudClient
{
public function isConfigured()
{
return trim((string) config('whatsapp.token')) !== ''
&& trim((string) config('whatsapp.phone_number_id')) !== '';
}
/**
* Malaysia MSISDN for Graph API, e.g. 60193450165.
*
* @return string|null
*/
public function normalizeMsisdn($raw)
{
$digits = preg_replace('/\D+/', '', (string) $raw);
if ($digits === '') {
return null;
}
if (strpos($digits, '60') === 0) {
return $digits;
}
if (strpos($digits, '0') === 0) {
return '60' . substr($digits, 1);
}
return '60' . $digits;
}
/**
* @param string $to
* @param string $templateName
* @param string $lang
* @param array<int,string> $bodyParams
* @return \Illuminate\Http\Client\Response
*/
public function sendTemplate($to, $templateName, $lang, array $bodyParams = [])
{
$payload = [
'messaging_product' => 'whatsapp',
'to' => $to,
'type' => 'template',
'template' => [
'name' => $templateName,
'language' => ['code' => $lang],
],
];
if (count($bodyParams) > 0) {
$payload['template']['components'] = [[
'type' => 'body',
'parameters' => array_map(function ($text) {
return [
'type' => 'text',
'text' => $this->sanitizeParam($text),
];
}, array_values($bodyParams)),
]];
}
$version = config('whatsapp.api_version', 'v25.0');
$phoneId = config('whatsapp.phone_number_id');
$url = 'https://graph.facebook.com/' . $version . '/' . $phoneId . '/messages';
$response = Http::withToken(config('whatsapp.token'))
->acceptJson()
->asJson()
->post($url, $payload);
if (!$response->successful()) {
Log::error('WhatsApp Cloud send failed', [
'to' => $to,
'template' => $templateName,
'status' => $response->status(),
'body' => $response->json() ?: $response->body(),
]);
}
return $response;
}
protected function sanitizeParam($text)
{
$text = trim(preg_replace('/\s+/', ' ', (string) $text));
if ($text === '') {
return '-';
}
return mb_substr($text, 0, 200);
}
}
+1
View File
@@ -93,6 +93,7 @@ $app->configure('tennant_database');
$app->configure('report');
$app->configure('app_parameter');
$app->configure('parameter');
$app->configure('whatsapp');
/*
|--------------------------------------------------------------------------
| Register Middleware
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'token' => env('WHATSAPP_TOKEN'),
'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'),
'api_version' => env('WHATSAPP_API_VERSION', 'v25.0'),
'template' => env('WHATSAPP_TEMPLATE', 'hello_world'),
'template_lang' => env('WHATSAPP_TEMPLATE_LANG', 'en_US'),
];