Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_user_billing.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_user_billing.cgi

added on local at 2026-07-11 18:37:01

Added
+309
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 852aefbac943
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# Super-admin per-user billing controls (BUILD-57 sister-site variant)
4# - Grant / debit account credit (credit_ledger)
5# - Override plan_tier (with optional expiry + reason)
6# - Lock a per-seat price (with optional expiry + reason)
7#
8# Sister-site companion to TaskForge's admin_company.cgi but scoped to
9# the users table (sister sites don't have a companies model).
10#======================================================================
11use strict;
12use warnings;
13
14use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
15use CGI;
16use MODS::Template;
17use MODS::Login;
18use MODS::ABForge::Wrapper;
19use MODS::DBConnect;
20use MODS::ABForge::Config;
21
22# Inline helpers (sister sites carry no MODS::*::PM with these)
23sub _safe_int { my $v = shift // ''; $v =~ s/[^0-9\-]//g; return $v ? $v + 0 : 0; }
24sub _safe_cents {
25 my $v = shift // ''; $v =~ s/[^0-9.\-]//g;
26 return int($v * 100 + ($v < 0 ? -0.5 : 0.5));
27}
28sub _safe_date { my $v = shift // ''; return ($v =~ /^(\d{4}-\d{2}-\d{2})$/) ? $1 : ''; }
29sub _sql_q { my $s = shift // ''; $s =~ s/\\/\\\\/g; $s =~ s/'/''/g; return $s; }
30sub _h { my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; return $s; }
31# ---- Viewer-local timestamp wrapper ---------------------------------
32sub _aub_ts_wrap {
33 my ($human, $epoch, $fmt) = @_;
34 $fmt = 'datetime' unless defined $fmt && length $fmt;
35 return '' unless defined($human) && length($human);
36 my $esc = _h($human);
37 return $esc unless $epoch && $epoch =~ /^\d+$/;
38 return qq~<span class="ts" data-ts="$epoch" data-fmt="$fmt">$esc</span>~;
39}
40sub _fmt_money {
41 my $c = int(shift || 0); my $neg = $c < 0 ? '-' : ''; $c = abs($c);
42 my $w = int($c/100); my $f = sprintf('%02d', $c % 100);
43 1 while $w =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
44 return "$neg\$$w.$f";
45}
46
47my $q = CGI->new;
48my $form = $q->Vars;
49my $auth = MODS::Login->new;
50my $wrap = MODS::ABForge::Wrapper->new;
51my $db = MODS::DBConnect->new;
52my $cfg = MODS::ABForge::Config->new;
53my $DB = $cfg->settings('database_name');
54
55my $u = $auth->login_verify();
56unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
57unless ($u->{is_super_admin}) { print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; exit; }
58
59my $target_uid = _safe_int($form->{id});
60unless ($target_uid) { print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit; }
61my $admin_uid = ($u->{user_id} || 0) + 0;
62
63my $dbh = $db->db_connect();
64
65sub _exec {
66 my $sql = shift;
67 local *OLDOUT;
68 open(OLDOUT, '>&', \*STDOUT) or do {};
69 open(STDOUT, '>', '/dev/null') or do {};
70 eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_user_billing', __LINE__) };
71 open(STDOUT, '>&', \*OLDOUT) or do {};
72}
73sub _query {
74 my $sql = shift;
75 local *OLDOUT;
76 open(OLDOUT, '>&', \*STDOUT) or do {};
77 open(STDOUT, '>', '/dev/null') or do {};
78 my @rows = eval { $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_user_billing', __LINE__) };
79 @rows = () unless @rows;
80 open(STDOUT, '>&', \*OLDOUT) or do {};
81 return @rows;
82}
83
84my $flash = '';
85my $act = $form->{act} // '';
86
87if ($act eq 'grant_credit' && $q->request_method eq 'POST') {
88 my $amt = _safe_cents($form->{amount});
89 my $exp = _safe_date($form->{expires_at});
90 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
91 if ($amt) {
92 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
93 _exec("INSERT INTO `${DB}`.credit_ledger (user_id,amount_cents,currency,reason,expires_at,granted_by_user_id) VALUES ($target_uid,$amt,'USD','$reason',$exp_sql,$admin_uid)");
94 $flash = ($amt > 0 ? "Credited " : "Debited ") . _fmt_money(abs($amt)) . ".";
95 }
96} elsif ($act eq 'set_tier_override' && $q->request_method eq 'POST') {
97 my $tier = _sql_q(substr($form->{tier} // '', 0, 40));
98 my $exp = _safe_date($form->{expires_at});
99 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
100 if (length $tier) {
101 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
102 _exec("UPDATE `${DB}`.users SET plan_tier_override='$tier', plan_tier_override_expires_at=$exp_sql, plan_tier_override_reason='$reason' WHERE id=$target_uid");
103 $flash = "Tier override set to $tier.";
104 }
105} elsif ($act eq 'clear_tier_override' && $q->request_method eq 'POST') {
106 _exec("UPDATE `${DB}`.users SET plan_tier_override=NULL, plan_tier_override_expires_at=NULL, plan_tier_override_reason=NULL WHERE id=$target_uid");
107 $flash = "Tier override cleared.";
108} elsif ($act eq 'set_locked_price' && $q->request_method eq 'POST') {
109 my $cents = _safe_cents($form->{amount});
110 my $exp = _safe_date($form->{expires_at});
111 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
112 if ($cents > 0) {
113 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
114 _exec("UPDATE `${DB}`.users SET locked_price_cents_per_seat=$cents, locked_price_expires_at=$exp_sql, locked_price_reason='$reason' WHERE id=$target_uid");
115 $flash = "Locked price set to " . _fmt_money($cents) . "/seat.";
116 }
117} elsif ($act eq 'clear_locked_price' && $q->request_method eq 'POST') {
118 _exec("UPDATE `${DB}`.users SET locked_price_cents_per_seat=NULL, locked_price_expires_at=NULL, locked_price_reason=NULL WHERE id=$target_uid");
119 $flash = "Locked price cleared.";
120}
121
122my @u_rows = _query("SELECT *, UNIX_TIMESTAMP(plan_tier_override_expires_at) AS plan_tier_override_expires_at_epoch, UNIX_TIMESTAMP(locked_price_expires_at) AS locked_price_expires_at_epoch FROM `${DB}`.users WHERE id=$target_uid LIMIT 1");
123unless (@u_rows && $u_rows[0]->{id}) {
124 $db->db_disconnect($dbh);
125 print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit;
126}
127my $usr = $u_rows[0];
128
129my @bal = _query("SELECT COALESCE(SUM(amount_cents),0) AS c FROM `${DB}`.credit_ledger WHERE user_id=$target_uid AND (expires_at IS NULL OR expires_at > NOW())");
130my $bal_cents = ($bal[0] && defined $bal[0]->{c}) ? $bal[0]->{c} + 0 : 0;
131
132my @ledger = _query("SELECT cl.id, cl.amount_cents, cl.reason, cl.expires_at, cl.created_at, UNIX_TIMESTAMP(cl.created_at) AS created_at_epoch, UNIX_TIMESTAMP(cl.expires_at) AS expires_at_epoch, gu.display_name AS by_name FROM `${DB}`.credit_ledger cl LEFT JOIN `${DB}`.users gu ON gu.id=cl.granted_by_user_id WHERE cl.user_id=$target_uid ORDER BY cl.created_at DESC LIMIT 100");
133
134$db->db_disconnect($dbh);
135
136my $tier_active = ($usr->{plan_tier_override} && length $usr->{plan_tier_override}) ? 1 : 0;
137my $lock_active = (defined $usr->{locked_price_cents_per_seat} && length $usr->{locked_price_cents_per_seat}) ? 1 : 0;
138my $lock_disp = $lock_active ? sprintf('%.2f', $usr->{locked_price_cents_per_seat}/100) : '';
139
140my $disp_name = _h($usr->{display_name} || $usr->{email} || "user $target_uid");
141my $email = _h($usr->{email} // '');
142my $tier = _h($usr->{plan_tier} // '');
143my $status = _h($usr->{account_status} // '');
144my $bal_disp = _fmt_money($bal_cents);
145
146my $ledger_rows = '';
147foreach my $r (@ledger) {
148 my $amt_n = $r->{amount_cents} || 0;
149 my $sign = $amt_n > 0 ? 'erng-pos' : ($amt_n < 0 ? 'erng-neg' : '');
150 my $amt = _fmt_money($amt_n);
151 my $by = _h($r->{by_name} // 'system');
152 my $rsn = _h($r->{reason} // '');
153 my $when = _aub_ts_wrap($r->{created_at}, $r->{created_at_epoch}, 'datetime');
154 my $exp = _aub_ts_wrap($r->{expires_at}, $r->{expires_at_epoch}, 'datetime');
155 $ledger_rows .= qq~<tr><td>$when</td><td class="t-right $sign">$amt</td><td>$by</td><td>$rsn</td><td>$exp</td></tr>~;
156}
157$ledger_rows ||= qq~<tr><td colspan="5" class="erng-empty">No credit history yet.</td></tr>~;
158
159my $flash_html = length($flash) ? qq~<div class="erng-flash">${\ _h($flash) }</div>~ : '';
160
161my $tier_active_block = '';
162if ($tier_active) {
163 my $ov = _h($usr->{plan_tier_override});
164 my $ov_exp = $usr->{plan_tier_override_expires_at} ? " &middot; expires " . _aub_ts_wrap($usr->{plan_tier_override_expires_at}, $usr->{plan_tier_override_expires_at_epoch}, 'datetime') : '';
165 my $ov_rsn = length($usr->{plan_tier_override_reason} // '') ? "<div class='reason'>" . _h($usr->{plan_tier_override_reason}) . "</div>" : '';
166 $tier_active_block = qq~
167 <div class="erng-active-pill">
168 <strong>Active override:</strong> $ov$ov_exp
169 $ov_rsn
170 <form method="POST" action="/admin_user_billing.cgi" style="display:inline">
171 <input type="hidden" name="act" value="clear_tier_override">
172 <input type="hidden" name="id" value="$target_uid">
173 <button type="submit" class="erng-btn-ghost">Clear override</button>
174 </form>
175 </div>~;
176}
177
178my $lock_active_block = '';
179if ($lock_active) {
180 my $lk_exp = $usr->{locked_price_expires_at} ? " &middot; expires " . _aub_ts_wrap($usr->{locked_price_expires_at}, $usr->{locked_price_expires_at_epoch}, 'datetime') : '';
181 my $lk_rsn = length($usr->{locked_price_reason} // '') ? "<div class='reason'>" . _h($usr->{locked_price_reason}) . "</div>" : '';
182 $lock_active_block = qq~
183 <div class="erng-active-pill">
184 <strong>Active locked price:</strong> \$$lock_disp / seat$lk_exp
185 $lk_rsn
186 <form method="POST" action="/admin_user_billing.cgi" style="display:inline">
187 <input type="hidden" name="act" value="clear_locked_price">
188 <input type="hidden" name="id" value="$target_uid">
189 <button type="submit" class="erng-btn-ghost">Clear lock</button>
190 </form>
191 </div>~;
192}
193
194my $body = qq~
195<style>
196.adu-hero { padding: 22px 24px; background: linear-gradient(135deg, rgba(34,197,94,0.10), rgba(90,169,255,0.06)); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; margin-bottom: 22px; }
197.adu-hero h1 { margin: 0 0 6px; font-size: 24px; color: #fff; }
198.adu-hero .sub { color: #8893a4; font-size: 13px; }
199.adu-hero .meta { margin-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; }
200.adu-hero .meta span { color: #cfd6e0; }
201.adu-hero .meta strong { color: #fff; }
202.erng-flash { padding: 12px 16px; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.36); color: #4ade80; border-radius: 8px; margin-bottom: 18px; font-size: 13px; font-weight: 600; }
203.erng-panel { background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; padding: 20px 22px; margin-bottom: 18px; }
204.erng-panel-head { margin-bottom: 14px; }
205.erng-panel-title { font-size: 16px; font-weight: 700; color: #fff; }
206.erng-panel-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
207.erng-pos { color: #4ade80; }
208.erng-neg { color: #f87171; }
209.erng-empty { text-align: center; padding: 24px; color: #7a8392; font-size: 13px; font-style: italic; }
210table.erng-table { width: 100%; border-collapse: collapse; font-size: 13px; }
211table.erng-table th, table.erng-table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.05); color: #cfd6e0; }
212table.erng-table th { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
213.t-right { text-align: right; }
214.adu-form { display: grid; grid-template-columns: 1fr 140px 140px 1fr auto; gap: 10px 12px; align-items: end; }
215.adu-form label { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; display: flex; flex-direction: column; gap: 4px; }
216.adu-form input, .adu-form select { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px 10px; border-radius: 7px; font-size: 13px; font-weight: 400; text-transform: none; letter-spacing: normal; }
217.erng-btn { padding: 7px 14px; background: #5aa9ff; color: #0a0f17; border: 0; border-radius: 7px; font-weight: 700; font-size: 13px; cursor: pointer; }
218.erng-btn:hover { background: #7cc1ff; }
219.erng-btn-ghost { padding: 5px 10px; background: rgba(255,255,255,0.06); color: #cfd6e0; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; font-size: 12px; cursor: pointer; margin-left: 8px; }
220.erng-active-pill { padding: 12px 14px; background: rgba(168,85,247,0.10); border: 1px solid rgba(168,85,247,0.36); border-radius: 8px; margin-bottom: 14px; font-size: 13px; color: #cfd6e0; }
221.erng-active-pill .reason { font-size: 12px; color: #8893a4; margin-top: 4px; }
222</style>
223
224<div class="adu-hero">
225 <h1>$disp_name</h1>
226 <p class="sub">Super-admin billing controls for this user.</p>
227 <div class="meta">
228 <span>Email: <strong>$email</strong></span>
229 <span>Plan: <strong>$tier</strong></span>
230 <span>Status: <strong>$status</strong></span>
231 <span>User ID: <strong>$target_uid</strong></span>
232 </div>
233</div>
234
235$flash_html
236
237<div class="erng-panel">
238 <div class="erng-panel-head">
239 <div class="erng-panel-title">Account credit</div>
240 <div class="erng-panel-sub">Current balance: <strong>$bal_disp</strong>. Positive amounts grant credit; negative amounts debit (e.g. reversal).</div>
241 </div>
242 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
243 <input type="hidden" name="act" value="grant_credit">
244 <input type="hidden" name="id" value="$target_uid">
245 <label>Amount (\$) <input type="number" name="amount" step="0.01" placeholder="50.00 or -25.00" required></label>
246 <label>Expires <input type="date" name="expires_at"></label>
247 <label>Reason <input type="text" name="reason" placeholder="Goodwill / refund / promo"></label>
248 <div></div>
249 <div><button type="submit" class="erng-btn">Apply</button></div>
250 </form>
251</div>
252
253<div class="erng-panel">
254 <div class="erng-panel-head">
255 <div class="erng-panel-title">Plan-tier override</div>
256 <div class="erng-panel-sub">Forces this user onto a different plan_tier regardless of their subscription. Use sparingly &mdash; for grandfathered customers, comped accounts, or temporary upgrades.</div>
257 </div>
258 $tier_active_block
259 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
260 <input type="hidden" name="act" value="set_tier_override">
261 <input type="hidden" name="id" value="$target_uid">
262 <label>Tier
263 <select name="tier" required>
264 <option value="">--</option>
265 <option value="free">free</option>
266 <option value="starter">starter</option>
267 <option value="pro">pro</option>
268 <option value="business">business</option>
269 <option value="studio">studio</option>
270 </select>
271 </label>
272 <label>Expires <input type="date" name="expires_at"></label>
273 <label></label>
274 <label>Reason <input type="text" name="reason" placeholder="Why this override?"></label>
275 <div><button type="submit" class="erng-btn">Set</button></div>
276 </form>
277</div>
278
279<div class="erng-panel">
280 <div class="erng-panel-head">
281 <div class="erng-panel-title">Locked per-seat price</div>
282 <div class="erng-panel-sub">Pins this user's per-seat price in cents. Overrides any tier-based pricing until cleared or expired. Useful for early-adopter pricing or negotiated rates.</div>
283 </div>
284 $lock_active_block
285 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
286 <input type="hidden" name="act" value="set_locked_price">
287 <input type="hidden" name="id" value="$target_uid">
288 <label>Price / seat (\$) <input type="number" name="amount" step="0.01" placeholder="9.00" required></label>
289 <label>Expires <input type="date" name="expires_at"></label>
290 <label></label>
291 <label>Reason <input type="text" name="reason" placeholder="Why this lock?"></label>
292 <div><button type="submit" class="erng-btn">Set</button></div>
293 </form>
294</div>
295
296<div class="erng-panel">
297 <div class="erng-panel-head">
298 <div class="erng-panel-title">Credit history</div>
299 <div class="erng-panel-sub">Every grant and debit on this user's ledger.</div>
300 </div>
301 <table class="erng-table">
302 <thead><tr><th>When</th><th class="t-right">Amount</th><th>By</th><th>Reason</th><th>Expires</th></tr></thead>
303 <tbody>$ledger_rows</tbody>
304 </table>
305</div>
306~;
307
308print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n";
309$wrap->render({ userinfo => $u, page_key => 'admin_users', title => "Billing &mdash; $disp_name", body => $body });