├── 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 |
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 |
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 |
--------------------------------------------------------------------------------