├── README.md ├── ui ├── duitku_channel.tpl └── duitku.tpl ├── channel_duitku.json └── duitku.php /README.md: -------------------------------------------------------------------------------- 1 | # Payment gateway Duitku 2 | 3 | payment gateway Duitku untuk PHPNuxBill 4 | 5 | [Download](https://github.com/hotspotbilling/phpnuxbill-duitku/archive/refs/heads/master.zip) 6 | 7 | ## instalasi 8 | 9 | Copy **duitku.php** dan **channel_duitku.json** ke folder **system/paymentgateway/** 10 | 11 | Copy isi folder **ui** ke folder **system/paymentgateway/ui/** 12 | 13 | 14 | ## Author 15 | 16 | [Ibnu Maksum aka ibnux](https://github.com/ibnux) 17 | 18 | ## Donations 19 | 20 | ### International 21 | [Github Sponsor](https://github.com/sponsors/ibnux) 22 | 23 | ### Indonesia 24 | [Trakteer iBNuX](https://trakteer.id/ibnux) 25 | -------------------------------------------------------------------------------- /ui/duitku_channel.tpl: -------------------------------------------------------------------------------- 1 | {include file="user-ui/header.tpl"} 2 |
3 |
4 |
5 |
Duitku {Lang::T('Payment Channel')}
6 |
7 | {foreach $channels as $channel} 8 | {if in_array($channel['id'], $duitku_channels)} 9 |
10 | {$channel['name']} 13 |
14 | {/if} 15 | {/foreach} 16 |
17 |
18 |
19 | {include file="user-ui/footer.tpl"} 20 | -------------------------------------------------------------------------------- /channel_duitku.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "BC", 4 | "name": "BCA" 5 | }, 6 | { 7 | "id": "M2", 8 | "name": "Mandiri" 9 | }, 10 | { 11 | "id": "VA", 12 | "name": "Maybank" 13 | }, 14 | { 15 | "id": "I1", 16 | "name": "BNI" 17 | }, 18 | { 19 | "id": "B1", 20 | "name": "CIMB Niaga" 21 | }, 22 | { 23 | "id": "BT", 24 | "name": "Permata Bank" 25 | }, 26 | { 27 | "id": "A1", 28 | "name": "ATM Bersama" 29 | }, 30 | { 31 | "id": "AG", 32 | "name": "Artha Graha" 33 | }, 34 | { 35 | "id": "BR", 36 | "name": "BRIVA" 37 | }, 38 | { 39 | "id": "S1", 40 | "name": "Bank Sahabat Sampoerna" 41 | }, 42 | { 43 | "id": "OV", 44 | "name": "OVO" 45 | }, 46 | { 47 | "id": "SA", 48 | "name": "Shopee Pay" 49 | }, 50 | { 51 | "id": "LF", 52 | "name": "LinkAja Apps (Fixed Fee)" 53 | }, 54 | { 55 | "id": "LA", 56 | "name": "LinkAja Apps (Percentage Fee)" 57 | }, 58 | { 59 | "id": "DA", 60 | "name": "DANA" 61 | }, 62 | { 63 | "id": "SP", 64 | "name": "QRIS Shopee Pay" 65 | }, 66 | { 67 | "id": "LQ", 68 | "name": "QRIS LinkAja" 69 | }, 70 | { 71 | "id": "NQ", 72 | "name": "QRIS Nobu" 73 | }, 74 | { 75 | "id": "FT", 76 | "name": "Pegadaian/ALFA/Pos" 77 | }, 78 | { 79 | "id": "A2", 80 | "name": "POS Indonesia" 81 | }, 82 | { 83 | "id": "IR", 84 | "name": "Indomaret" 85 | }, 86 | { 87 | "id": "VC", 88 | "name": "Credit Card (Visa / Master Card / JCB)" 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /ui/duitku.tpl: -------------------------------------------------------------------------------- 1 | {include file="sections/header.tpl"} 2 | 3 |
4 |
5 |
6 |
7 |
DUITKU
8 |
9 |
10 | 11 | 15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 | 24 | 28 |
29 | 30 |
31 | 32 |
33 | {foreach $channels as $channel} 34 | 35 | {/foreach} 36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
/ip hotspot walled-garden
44 | add dst-host=duitku.com
45 | add dst-host=*.duitku.com
46 | {Lang::T('Set Telegram Bot to get any error and notification')} 47 |
48 |
49 | 50 |
51 |
52 |
53 | 54 | {include file="sections/footer.tpl"} 55 | -------------------------------------------------------------------------------- /duitku.php: -------------------------------------------------------------------------------- 1 | assign('_title', 'Duitku - Payment Gateway'); 23 | $ui->assign('channels', json_decode(file_get_contents('system/paymentgateway/channel_duitku.json'), true)); 24 | $ui->display('duitku.tpl'); 25 | } 26 | 27 | function duitku_save_config() 28 | { 29 | global $admin; 30 | $duitku_merchant_id = _post('duitku_merchant_id'); 31 | $duitku_merchant_key = _post('duitku_merchant_key'); 32 | $d = ORM::for_table('tbl_appconfig')->where('setting', 'duitku_merchant_id')->find_one(); 33 | if ($d) { 34 | $d->value = $duitku_merchant_id; 35 | $d->save(); 36 | } else { 37 | $d = ORM::for_table('tbl_appconfig')->create(); 38 | $d->setting = 'duitku_merchant_id'; 39 | $d->value = $duitku_merchant_id; 40 | $d->save(); 41 | } 42 | $d = ORM::for_table('tbl_appconfig')->where('setting', 'duitku_merchant_key')->find_one(); 43 | if ($d) { 44 | $d->value = $duitku_merchant_key; 45 | $d->save(); 46 | } else { 47 | $d = ORM::for_table('tbl_appconfig')->create(); 48 | $d->setting = 'duitku_merchant_key'; 49 | $d->value = $duitku_merchant_key; 50 | $d->save(); 51 | } 52 | $d = ORM::for_table('tbl_appconfig')->where('setting', 'duitku_channel')->find_one(); 53 | if ($d) { 54 | $d->value = implode(',', $_POST['duitku_channel']); 55 | $d->save(); 56 | } else { 57 | $d = ORM::for_table('tbl_appconfig')->create(); 58 | $d->setting = 'duitku_channel'; 59 | $d->value = implode(',', $_POST['duitku_channel']); 60 | $d->save(); 61 | } 62 | _log('[' . $admin['username'] . ']: Duitku ' . Lang::T('Settings_Saved_Successfully'), 'Admin', $admin['id']); 63 | r2(U . 'paymentgateway/duitku', 's', Lang::T('Settings_Saved_Successfully')); 64 | } 65 | 66 | function duitku_create_transaction($trx, $user) 67 | { 68 | global $config, $routes, $ui; 69 | 70 | $channels = json_decode(file_get_contents('system/paymentgateway/channel_duitku.json'), true); 71 | if (!in_array($routes[4], explode(",", $config['duitku_channel']))) { 72 | $ui->assign('_title', 'Duitku Channel'); 73 | $ui->assign('channels', $channels); 74 | $ui->assign('duitku_channels', explode(",", $config['duitku_channel'])); 75 | $ui->assign('path', $routes['2'] . '/' . $routes['3']); 76 | $ui->display('duitku_channel.tpl'); 77 | die(); 78 | } 79 | 80 | $json = [ 81 | 'paymentMethod' => $routes[4], 82 | 'paymentAmount' => $trx['price'], 83 | 'merchantCode' => $config['duitku_merchant_id'], 84 | 'merchantOrderId' => $trx['id'], 85 | 'productDetails' => $trx['plan_name'], 86 | 'merchantUserInfo' => $user['fullname'], 87 | 'customerVaName' => $user['fullname'], 88 | 'email' => (empty($user['email'])) ? $user['username'] . '@' . $_SERVER['HTTP_HOST'] : $user['email'], 89 | 'phoneNumber' => $user['phonenumber'], 90 | 'itemDetails' => [ 91 | [ 92 | 'name' => $trx['plan_name'], 93 | 'price' => $trx['price'], 94 | 'quantity' => 1 95 | ] 96 | ], 97 | 'returnUrl' => U . 'order/view/' . $trx['id'] . '/check', 98 | 'signature' => md5($config['duitku_merchant_id'] . $trx['id'] . $trx['price'] . $config['duitku_merchant_key']) 99 | ]; 100 | 101 | $result = json_decode(Http::postJsonData(duitku_get_server() . 'v2/inquiry', $json), true); 102 | 103 | if (empty($result['paymentUrl'])) { 104 | Message::sendTelegram("Duitku payment failed\n\n" . json_encode($result, JSON_PRETTY_PRINT)); 105 | r2(U . 'order/package', 'e', Lang::T("Failed to create transaction.")); 106 | } 107 | $d = ORM::for_table('tbl_payment_gateway') 108 | ->where('username', $user['username']) 109 | ->where('status', 1) 110 | ->find_one(); 111 | $d->gateway_trx_id = $result['reference']; 112 | $d->pg_url_payment = $result['paymentUrl']; 113 | $d->payment_method = $routes['4']; 114 | foreach ($channels as $channel) { 115 | if ($channel['id'] == $routes['4']) { 116 | $d->payment_channel = $channel['name']; 117 | break; 118 | } 119 | } 120 | $d->pg_request = json_encode($result); 121 | $d->expired_date = date('Y-m-d H:i:s', strtotime("+1 day")); 122 | $d->save(); 123 | r2(U . "order/view/" . $d['id'], 's', Lang::T("Create Transaction Success")); 124 | } 125 | 126 | function duitku_get_status($trx, $user) 127 | { 128 | global $config; 129 | $json = [ 130 | 'merchantCode' => $config['duitku_merchant_id'], 131 | 'merchantOrderId' => $trx['id'], 132 | 'signature' => md5($config['duitku_merchant_id'] . $trx['id'] . $config['duitku_merchant_key']) 133 | ]; 134 | $result = json_decode(Http::postJsonData(duitku_get_server() . 'transactionStatus', $json), true); 135 | if ($result['reference'] != $trx['gateway_trx_id']) { 136 | Message::sendTelegram("Duitku payment status failed\n\n" . json_encode($result, JSON_PRETTY_PRINT)); 137 | r2(U . "order/view/" . $trx['id'], 'w', Lang::T("Payment check failed.")); 138 | } 139 | if ($result['statusCode'] == '01') { 140 | r2(U . "order/view/" . $trx['id'], 'w', Lang::T("Transaction still unpaid.")); 141 | } else if ($result['statusCode'] == '00' && $trx['status'] != 2) { 142 | if (!Package::rechargeUser($user['id'], $trx['routers'], $trx['plan_id'], $trx['gateway'], $trx['payment_channel'])) { 143 | r2(U . "order/view/" . $trx['id'], 'd', Lang::T("Failed to activate your Package, try again later.")); 144 | } 145 | 146 | // ngambil nomor invoice dr tbl_transactions 147 | $inv = null; 148 | $methodWant = 'duitku - ' . $trx['payment_channel']; 149 | $invQ = ORM::for_table('tbl_transactions') 150 | ->where('user_id', (int)$user['id']) 151 | ->where('price', (int)$trx['price']) 152 | ->where('method', $methodWant) 153 | ->where_like('invoice', 'INV-%') 154 | ->order_by_desc('id'); 155 | $inv = $invQ->find_one(); 156 | if ($inv && empty($trx->trx_invoice)) { 157 | $trx->trx_invoice = $inv['invoice']; 158 | } 159 | 160 | $trx->pg_paid_response = json_encode($result); 161 | $trx->paid_date = date('Y-m-d H:i:s'); 162 | $trx->status = 2; 163 | $trx->save(); 164 | 165 | r2(U . "order/view/" . $trx['id'], 's', Lang::T("Transaction has been paid.")); 166 | } else if ($result['statusCode'] == '02') { 167 | $trx->pg_paid_response = json_encode($result); 168 | $trx->status = 3; 169 | $trx->save(); 170 | r2(U . "order/view/" . $trx['id'], 'd', Lang::T("Transaction expired or Failed.")); 171 | } else if ($trx['status'] == 2) { 172 | r2(U . "order/view/" . $trx['id'], 'd', Lang::T("Transaction has been paid..")); 173 | } 174 | } 175 | 176 | // helper 177 | function _r2_if_not_callback($url, $type, $msg) { 178 | if (defined('IS_GATEWAY_CALLBACK') && IS_GATEWAY_CALLBACK) { 179 | return; // jangan redirect/exit saat dari callback 180 | } 181 | r2($url, $type, $msg); 182 | } 183 | 184 | // callback 185 | function duitku_payment_notification() 186 | { 187 | http_response_code(200); // hindari retry dari Duitku 188 | 189 | // log 1x aja (cuman buat error/gating) 190 | $logOnce = function (string $title, array $data = []) { 191 | static $sent = false; if ($sent) return; 192 | foreach ($data as $k => $v) if (is_array($v) || is_object($v)) $data[$k] = json_encode($v, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); 193 | $msg = "[DUITKU CB] {$title}\nTime: " . date('Y-m-d H:i:s'); 194 | foreach ($data as $k => $v) $msg .= "\n{$k}: {$v}"; 195 | if (strlen($msg) > 3500) $msg = substr($msg, 0, 3500).'…(truncated)'; 196 | if (class_exists('Message')) Message::sendTelegram($msg); 197 | $sent = true; 198 | }; 199 | 200 | // parse payload (x-www-form-urlencoded / JSON fallback) 201 | $ct = $_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? ''); 202 | $raw = @file_get_contents('php://input'); 203 | $post = $_POST; 204 | if (empty($post)) { 205 | if (stripos($ct, 'application/json') !== false) $post = json_decode($raw, true) ?: []; 206 | else parse_str($raw, $post); 207 | } 208 | 209 | // minimal fields 210 | foreach (['merchantCode','amount','merchantOrderId','resultCode','signature'] as $k) { 211 | if (!isset($post[$k])) { $logOnce('Missing field', ['field'=>$k]); echo 'OK'; return; } 212 | } 213 | 214 | // validasi signature: md5(merchantCode + amount + merchantOrderId + apiKey) 215 | global $config; 216 | $apiKey = (string)($config['duitku_merchant_key'] ?? ''); 217 | $expected = md5($post['merchantCode'].$post['amount'].$post['merchantOrderId'].$apiKey); 218 | if (strcasecmp($expected, (string)$post['signature']) !== 0) { 219 | $logOnce('Signature mismatch', ['merchantOrderId'=>$post['merchantOrderId']]); 220 | echo 'OK'; return; 221 | } 222 | 223 | // key reference -> gateway_trx_id 224 | $reference = (string)($post['reference'] ?? ''); 225 | if ($reference === '') { $logOnce('Reference empty'); echo 'OK'; return; } 226 | 227 | // ngambil transaksi 228 | $trx = ORM::for_table('tbl_payment_gateway') 229 | ->where('gateway','duitku') 230 | ->where('gateway_trx_id',$reference) 231 | ->order_by_desc('id') 232 | ->find_one(); 233 | if (!$trx) { $logOnce('Transaction not found', ['reference'=>$reference]); echo 'OK'; return; } 234 | 235 | // local gating: skip jika sudah paid / invoice sudah ada biar gak recharge berkali" waktu resend callback di pg (tanpa log) 236 | if ((string)$trx['status'] === '2' || (string)$trx['status'] === 'paid' || !empty($trx['trx_invoice'])) { 237 | echo 'OK'; return; 238 | } 239 | 240 | // ambil user (ORM) 241 | $user = null; 242 | if (!empty($trx['user_id'])) { 243 | $user = ORM::for_table('tbl_customers')->find_one((int)$trx['user_id']); 244 | } elseif (!empty($trx['username'])) { 245 | $user = ORM::for_table('tbl_customers')->where('username',$trx['username'])->find_one(); 246 | } 247 | if (!$user) { $logOnce('User not found', ['user_id'=>$trx['user_id'], 'username'=>$trx['username']]); echo 'OK'; return; } 248 | 249 | // trigger finishing lewat helper existing (ORM object) 250 | if (function_exists('duitku_get_status')) { 251 | if (!defined('IS_GATEWAY_CALLBACK')) define('IS_GATEWAY_CALLBACK', true); 252 | try { 253 | ob_start(); 254 | duitku_get_status($trx, $user); 255 | ob_end_clean(); 256 | } catch (Throwable $e) { 257 | $logOnce('Finishing error', ['trxId'=>$trx['id'], 'err'=>$e->getMessage()]); 258 | echo 'OK'; return; 259 | } 260 | 261 | // cek hasil akhir: kalau belum berubah, kirim log kalau sukses, kayak biasa aja 262 | $fresh = ORM::for_table('tbl_payment_gateway')->find_one($trx['id']); 263 | $ok = ($fresh && ((string)$fresh['status'] === '2' || !empty($fresh['trx_invoice']))); 264 | if (!$ok) { $logOnce('Finishing did not mark paid', ['trxId'=>$trx['id']]); } 265 | } else { 266 | $logOnce('Helper duitku_get_status not found'); 267 | } 268 | echo 'OK'; 269 | } 270 | 271 | function duitku_get_server() 272 | { 273 | global $_app_stage; 274 | if ($_app_stage == 'Live') { 275 | return 'https://passport.duitku.com/webapi/api/merchant/'; 276 | } else { 277 | return 'https://sandbox.duitku.com/webapi/api/merchant/'; 278 | } 279 | } 280 | --------------------------------------------------------------------------------