payments.php

main 361 lines · 13.5 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2/**
3 * Title: Payments
4 * Icon: fa-credit-card
5 * Group: Economy
6 * Order: 15
7 * Description: Gateways, credentials and the point packages players can buy.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15function acp_payments_schema(): array {
16 return require __DIR__ . '/_partials/payments_schema.php';
17}
18
19function acp_payment_cast(string $type, $raw): string {
20 switch ($type) {
21 case 'bool': return empty($raw) ? '0' : '1';
22 case 'int': return (string)intv($raw);
23 default: return trim((string)$raw);
24 }
25}
26
27// ---------------------------------------------------------------------------
28// Save
29// ---------------------------------------------------------------------------
30if ($_SERVER['REQUEST_METHOD'] === 'POST') {
31
32 $saved = 0;
33 $failed = 0;
34 $savedKeys = array();
35
36 if (isset($_POST['pay']) && is_array($_POST['pay'])) {
37 $knownFields = array();
38 foreach (acp_payments_schema() as $group) {
39 $knownFields += $group['fields'];
40 }
41
42 foreach ($_POST['pay'] as $key => $raw) {
43 if (!isset($knownFields[$key])) {
44 continue;
45 }
46 $field = $knownFields[$key];
47 if (($field['type'] ?? '') === 'secret' && trim((string)$raw) === '') {
48 continue;
49 }
50 $value = acp_payment_cast($field['type'], $raw);
51 if (setting_set('config:' . $key, $value)) {
52 $saved++;
53 $savedKeys[] = $key;
54 } else $failed++;
55 }
56 }
57
58 // Price tiers arrive as parallel arrays and are stored as one JSON row,
59 // because the whole list is a single config value.
60 if (isset($_POST['tier_price']) && is_array($_POST['tier_price'])) {
61 $tiers = array();
62 $points = isset($_POST['tier_points']) && is_array($_POST['tier_points']) ? $_POST['tier_points'] : array();
63
64 foreach ($_POST['tier_price'] as $i => $price) {
65 $price = is_numeric($price) ? round((float)$price, 2) : 0;
66 $give = intv($points[$i] ?? 0);
67 if ($price > 0 && $give > 0) {
68 $tiers[number_format($price, 2, '.', '')] = $give;
69 }
70 }
71
72 ksort($tiers, SORT_NUMERIC);
73 if (setting_set('config:paypal_prices', json_encode($tiers, JSON_FORCE_OBJECT))) {
74 $saved++;
75 $savedKeys[] = 'paypal_prices';
76 } else $failed++;
77 }
78
79 if ($saved > 0) {
80 acp_log('payments.save', '', ['fields' => $savedKeys, 'failed' => $failed]);
81 }
82 if ($failed > 0) {
83 acp_flash_error(t('acp.pay.save_failed', ['n' => $failed, 'table' => '<code>znote_config</code>']));
84 } else {
85 acp_flash_success(t('acp.pay.save_success', ['n' => $saved]));
86 }
87
88 acp_redirect('payments');
89}
90
91$schema = acp_payments_schema();
92$hasTable = znote_table_exists('znote_config');
93
94if (function_exists('payment_gateway_ensure_schema')) {
95 payment_gateway_ensure_schema();
96}
97
98$storedTiers = setting('config:paypal_prices', null);
99$tiers = null;
100if ($storedTiers !== null) {
101 $decoded = json_decode($storedTiers, true);
102 if (is_array($decoded)) {
103 $tiers = $decoded;
104 }
105}
106if ($tiers === null) {
107 $tiers = is_array($config['paypal_prices'] ?? null) ? $config['paypal_prices'] : array();
108}
109ksort($tiers, SORT_NUMERIC);
110
111$perCurrency = (int)znote_config_path($config, 'paypal.points_per_currency', 0);
112$currency = (string)znote_config_path($config, 'paypal.currency', '');
113$paymentPage = max(1, intv($_GET['page'] ?? 1));
114$paymentPerPage = 50;
115$paymentTotal = znote_table_exists('znote_payment_transactions')
116 ? acp_count("SELECT COUNT(*) AS `c` FROM `znote_payment_transactions`;")
117 : 0;
118$paymentPages = max(1, (int)ceil($paymentTotal / $paymentPerPage));
119$paymentPage = min($paymentPage, $paymentPages);
120$paymentOffset = ($paymentPage - 1) * $paymentPerPage;
121$modernPayments = znote_table_exists('znote_payment_transactions')
122 ? db()->fetchAll("
123 SELECT `provider`, `reference`, `provider_reference`, `account_id`, `price`, `currency`, `points`, `status`, `credited`, `test_mode`, `created_at`, `credited_at`
124 FROM `znote_payment_transactions`
125 ORDER BY `id` DESC
126 LIMIT {$paymentOffset}, {$paymentPerPage};
127 ")
128 : false;
129$paymentEvents = znote_table_exists('znote_payment_events')
130 ? db()->fetchAll("
131 SELECT `provider`, `event_id`, `provider_reference`, `payment_reference`, `status`, `received_at`
132 FROM `znote_payment_events`
133 ORDER BY `id` DESC
134 LIMIT 50;
135 ")
136 : false;
137?>
138
139<?php if (!$hasTable): ?>
140 <div class="acp-flash acp-flash--error">
141 <i class="fa fa-exclamation-triangle"></i>
142 <span><?= t('acp.pay.table_missing_short', ['table' => '<code>znote_config</code>']) ?></span>
143 </div>
144<?php endif; ?>
145
146<div class="acp-flash acp-flash--info">
147 <i class="fa fa-info-circle"></i>
148 <span>
149 <?= t('acp.pay.stored_note', [
150 'configphp' => '<code>config.php</code>',
151 'znoteconfig' => '<code>znote_config</code>',
152 'configphp2' => '<code>config.php</code>',
153 ]) ?>
154 </span>
155</div>
156
157<section class="acp-card">
158 <header class="acp-card-head">
159 <h2><i class="fa fa-link"></i> Webhook URLs</h2>
160 <p>Configure these URLs in Stripe and Mercado Pago. Return pages do not credit points.</p>
161 </header>
162 <div class="acp-card-body">
163 <div class="acp-field">
164 <label class="acp-label">Stripe</label>
165 <input class="acp-input" type="text" readonly value="<?= h(function_exists('payment_gateway_webhook_url') ? payment_gateway_webhook_url('stripe') : '') ?>">
166 </div>
167 <div class="acp-field">
168 <label class="acp-label">Mercado Pago</label>
169 <input class="acp-input" type="text" readonly value="<?= h(function_exists('payment_gateway_webhook_url') ? payment_gateway_webhook_url('mercadopago') : '') ?>">
170 </div>
171 </div>
172</section>
173
174<form method="post">
175 <?= acp_csrf_field() ?>
176
177 <section class="acp-card">
178 <header class="acp-card-head">
179 <h2><i class="fa fa-shopping-cart"></i> <?= t('acp.pay.tiers_title') ?></h2>
180 <p><?= t('acp.pay.tiers_sub') ?></p>
181 </header>
182 <div class="acp-card-body">
183 <div class="acp-table-wrap">
184 <table class="acp-table" id="tierTable">
185 <tr class="yellow">
186 <td><?= t('acp.pay.col_price') ?><?= $currency !== '' ? ' (' . h($currency) . ')' : '' ?></td>
187 <td><?= t('acp.pay.col_points') ?></td>
188 <td><?= t('acp.pay.col_bonus') ?></td>
189 <td></td>
190 </tr>
191 <?php $rows = $tiers ?: array('' => ''); ?>
192 <?php foreach ($rows as $price => $points): ?>
193 <?php
194 $bonus = null;
195 if ($perCurrency > 0 && (float)$price > 0 && (int)$points > 0) {
196 $bonus = round(((int)$points / ((float)$price * $perCurrency) - 1) * 100);
197 }
198 ?>
199 <tr>
200 <td><input class="acp-input" type="number" min="0" step="0.01" name="tier_price[]" value="<?= h($price) ?>"></td>
201 <td><input class="acp-input" type="number" min="0" name="tier_points[]" value="<?= h($points) ?>"></td>
202 <td class="is-muted"><?= $bonus === null ? '&mdash;' : ($bonus > 0 ? '+' : '') . (int)$bonus . '%' ?></td>
203 <td><button type="button" class="acp-btn acp-btn--ghost" onclick="this.closest('tr').remove()"><i class="fa fa-times"></i></button></td>
204 </tr>
205 <?php endforeach; ?>
206 </table>
207 </div>
208 <div class="acp-actions">
209 <button type="button" class="acp-btn acp-btn--ghost" onclick="addTier()"><i class="fa fa-plus"></i> <?= t('acp.pay.add_package') ?></button>
210 </div>
211 <p class="acp-hint"><?= t('acp.pay.tiers_hint') ?></p>
212 <div class="acp-actions">
213 <button class="acp-btn acp-btn--green" type="submit" <?= $hasTable ? '' : 'disabled' ?>>
214 <i class="fa fa-check"></i> <?= t('acp.pay.save_btn') ?>
215 </button>
216 </div>
217 </div>
218 </section>
219</form>
220
221<div class="acp-grid acp-grid--2">
222 <?php foreach ($schema as $groupName => $group): ?>
223 <form method="post">
224 <?= acp_csrf_field() ?>
225 <section class="acp-card">
226 <header class="acp-card-head">
227 <h2><i class="fa <?= h($group['icon']) ?>"></i> <?= h($groupName) ?></h2>
228 <?php if (!empty($group['help'])): ?><p><?= h($group['help']) ?></p><?php endif; ?>
229 </header>
230 <div class="acp-card-body">
231 <?php foreach ($group['fields'] as $key => $field): ?>
232 <?php
233 $stored = setting('config:' . $key, null);
234 $fromFile = znote_config_path($config, $key, '');
235 if (is_bool($fromFile)) {
236 $fromFile = $fromFile ? '1' : '0';
237 } elseif (is_array($fromFile)) {
238 $fromFile = '';
239 }
240 $current = ($stored !== null) ? $stored : (string)$fromFile;
241 $isSecret = ($field['type'] ?? '') === 'secret';
242 ?>
243 <div class="acp-field">
244 <label class="acp-label" for="pay_<?= h($key) ?>">
245 <?= h($field['label']) ?>
246 <?php if ($stored === null): ?>
247 <span class="acp-pill acp-pill--grey" title="<?= h(t('acp.pay.following_title')) ?>"><?= t('acp.pay.file_pill') ?></span>
248 <?php endif; ?>
249 </label>
250
251 <?php if ($field['type'] === 'bool'): ?>
252 <label style="display:flex;align-items:center;gap:8px;font-weight:400;">
253 <input type="hidden" name="pay[<?= h($key) ?>]" value="0">
254 <input type="checkbox" id="pay_<?= h($key) ?>" name="pay[<?= h($key) ?>]" value="1"
255 <?= ($current !== '' && $current !== '0') ? 'checked' : '' ?>>
256 <span class="is-muted"><?= t('acp.pay.enabled') ?></span>
257 </label>
258 <?php else: ?>
259 <input class="acp-input" id="pay_<?= h($key) ?>" name="pay[<?= h($key) ?>]"
260 type="<?= $field['type'] === 'int' ? 'number' : ($field['type'] === 'secret' ? 'password' : 'text') ?>"
261 value="<?= $isSecret ? '' : h($current) ?>"
262 <?= $isSecret ? 'autocomplete="new-password" placeholder="' . h($current !== '' ? 'Configured - leave blank to keep' : '') . '"' : '' ?>>
263 <?php endif; ?>
264
265 <?php if (!empty($field['help'])): ?>
266 <p class="acp-hint"><?= h($field['help']) ?></p>
267 <?php endif; ?>
268 </div>
269 <?php endforeach; ?>
270
271 <div class="acp-actions">
272 <button class="acp-btn acp-btn--green" type="submit" <?= $hasTable ? '' : 'disabled' ?>>
273 <i class="fa fa-check"></i> <?= t('acp.pay.save_btn') ?>
274 </button>
275 </div>
276 </div>
277 </section>
278 </form>
279 <?php endforeach; ?>
280</div>
281
282<section class="acp-card">
283 <header class="acp-card-head">
284 <h2><i class="fa fa-history"></i> Recent Stripe / Mercado Pago Transactions</h2>
285 <p>Credited means the webhook has passed signature and provider verification.</p>
286 </header>
287 <div class="acp-card-body">
288 <?php if (is_array($modernPayments) && $modernPayments): ?>
289 <div class="acp-table-wrap">
290 <table class="acp-table">
291 <thead><tr><th>Provider</th><th>Reference</th><th>Account</th><th>Amount</th><th class="is-num">Points</th><th>Status</th><th>Created</th><th>Credited</th></tr></thead>
292 <tbody>
293 <?php foreach ($modernPayments as $row): ?>
294 <tr>
295 <td><?= h(ucfirst((string)$row['provider'])) ?><?= !empty($row['test_mode']) ? ' <span class="acp-pill acp-pill--grey">test</span>' : '' ?></td>
296 <td><code title="<?= h((string)$row['provider_reference']) ?>"><?= h((string)$row['reference']) ?></code></td>
297 <td class="is-num"><?= (int)$row['account_id'] ?></td>
298 <td><?= h(number_format((float)$row['price'], 2, '.', '')) ?> <?= h($row['currency']) ?></td>
299 <td class="is-num"><?= (int)$row['points'] ?></td>
300 <td><?= h($row['status']) ?><?= !empty($row['credited']) ? ' <span class="acp-pill acp-pill--green">credited</span>' : '' ?></td>
301 <td><?= !empty($row['created_at']) ? date('Y-m-d H:i', (int)$row['created_at']) : '-' ?></td>
302 <td><?= !empty($row['credited_at']) ? date('Y-m-d H:i', (int)$row['credited_at']) : '-' ?></td>
303 </tr>
304 <?php endforeach; ?>
305 </tbody>
306 </table>
307 </div>
308 <?php else: ?>
309 <?php acp_empty('No Stripe or Mercado Pago transaction yet.', 'fa-credit-card'); ?>
310 <?php endif; ?>
311 <?php if ($paymentPages > 1): ?>
312 <div class="acp-toolbar">
313 <span class="is-muted"><?= $paymentPage ?> / <?= $paymentPages ?></span>
314 <div class="acp-actions is-tight">
315 <?php if ($paymentPage > 1): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('payments', ['page' => $paymentPage - 1])) ?>"><?= t('common.previous') ?></a><?php endif; ?>
316 <?php if ($paymentPage < $paymentPages): ?><a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('payments', ['page' => $paymentPage + 1])) ?>"><?= t('common.next') ?></a><?php endif; ?>
317 </div>
318 </div>
319 <?php endif; ?>
320 </div>
321</section>
322
323<section class="acp-card">
324 <header class="acp-card-head">
325 <h2><i class="fa fa-exchange"></i> Recent webhook events</h2>
326 </header>
327 <div class="acp-card-body is-flush">
328 <?php if (is_array($paymentEvents) && $paymentEvents): ?>
329 <div class="acp-table-wrap">
330 <table class="acp-table">
331 <thead><tr><th>Provider</th><th>Event</th><th>Payment</th><th>Status</th><th>Received</th></tr></thead>
332 <tbody>
333 <?php foreach ($paymentEvents as $event): ?>
334 <tr>
335 <td><?= h(ucfirst((string)$event['provider'])) ?></td>
336 <td><code><?= h((string)$event['event_id']) ?></code></td>
337 <td><code title="<?= h((string)$event['provider_reference']) ?>"><?= h((string)$event['payment_reference']) ?></code></td>
338 <td><?= h((string)$event['status']) ?></td>
339 <td><?= date('Y-m-d H:i', (int)$event['received_at']) ?></td>
340 </tr>
341 <?php endforeach; ?>
342 </tbody>
343 </table>
344 </div>
345 <?php else: ?>
346 <?php acp_empty('No webhook event yet.', 'fa-exchange'); ?>
347 <?php endif; ?>
348 </div>
349</section>
350
351<script>
352function addTier() {
353 var table = document.getElementById('tierTable');
354 var row = table.insertRow(-1);
355 row.innerHTML = '<td><input class="acp-input" type="number" min="0" step="0.01" name="tier_price[]" value=""></td>'
356 + '<td><input class="acp-input" type="number" min="0" name="tier_points[]" value=""></td>'
357 + '<td class="is-muted">&mdash;</td>'
358 + '<td><button type="button" class="acp-btn acp-btn--ghost" onclick="this.closest(\'tr\').remove()"><i class="fa fa-times"></i></button></td>';
359}
360</script>
361
Top