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

O Operator
Diff

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

added on local at 2026-07-11 18:36:28

Added
+306
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 167370d9505f
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/shop.3dshawn.com';
15use CGI;
16use MODS::Template;
17use MODS::Login;
18use MODS::ShopCart::Wrapper;
19use MODS::DBConnect;
20use MODS::ShopCart::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; }
31sub _fmt_money {
32 my $c = int(shift || 0); my $neg = $c < 0 ? '-' : ''; $c = abs($c);
33 my $w = int($c/100); my $f = sprintf('%02d', $c % 100);
34 1 while $w =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
35 return "$neg\$$w.$f";
36}
37
38my $q = CGI->new;
39my $form = $q->Vars;
40my $auth = MODS::Login->new;
41my $wrap = MODS::ShopCart::Wrapper->new;
42my $db = MODS::DBConnect->new;
43my $cfg = MODS::ShopCart::Config->new;
44my $DB = $cfg->settings('database_name');
45
46my $u = $auth->login_verify();
47unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
48unless ($u->{is_super_admin}) { print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; exit; }
49
50my $target_uid = _safe_int($form->{id});
51unless ($target_uid) { print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit; }
52my $admin_uid = ($u->{user_id} || 0) + 0;
53
54my $dbh = $db->db_connect();
55
56sub _exec {
57 my $sql = shift;
58 local *OLDOUT;
59 open(OLDOUT, '>&', \*STDOUT) or do {};
60 open(STDOUT, '>', '/dev/null') or do {};
61 eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_user_billing', __LINE__) };
62 open(STDOUT, '>&', \*OLDOUT) or do {};
63}
64sub _query {
65 my $sql = shift;
66 local *OLDOUT;
67 open(OLDOUT, '>&', \*STDOUT) or do {};
68 open(STDOUT, '>', '/dev/null') or do {};
69 my @rows = eval { $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME} || 'admin_user_billing', __LINE__) };
70 @rows = () unless @rows;
71 open(STDOUT, '>&', \*OLDOUT) or do {};
72 return @rows;
73}
74
75my $flash = '';
76my $act = $form->{act} // '';
77
78if ($act eq 'grant_credit' && $q->request_method eq 'POST') {
79 my $amt = _safe_cents($form->{amount});
80 my $exp = _safe_date($form->{expires_at});
81 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
82 if ($amt) {
83 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
84 _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)");
85 $flash = ($amt > 0 ? "Credited " : "Debited ") . _fmt_money(abs($amt)) . ".";
86 }
87} elsif ($act eq 'set_tier_override' && $q->request_method eq 'POST') {
88 my $tier = _sql_q(substr($form->{tier} // '', 0, 40));
89 my $exp = _safe_date($form->{expires_at});
90 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
91 if (length $tier) {
92 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
93 _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");
94 $flash = "Tier override set to $tier.";
95 }
96} elsif ($act eq 'clear_tier_override' && $q->request_method eq 'POST') {
97 _exec("UPDATE `${DB}`.users SET plan_tier_override=NULL, plan_tier_override_expires_at=NULL, plan_tier_override_reason=NULL WHERE id=$target_uid");
98 $flash = "Tier override cleared.";
99} elsif ($act eq 'set_locked_price' && $q->request_method eq 'POST') {
100 my $cents = _safe_cents($form->{amount});
101 my $exp = _safe_date($form->{expires_at});
102 my $reason = _sql_q(substr($form->{reason} // '', 0, 255));
103 if ($cents > 0) {
104 my $exp_sql = $exp ? "'$exp 23:59:59'" : 'NULL';
105 _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");
106 $flash = "Locked price set to " . _fmt_money($cents) . "/seat.";
107 }
108} elsif ($act eq 'clear_locked_price' && $q->request_method eq 'POST') {
109 _exec("UPDATE `${DB}`.users SET locked_price_cents_per_seat=NULL, locked_price_expires_at=NULL, locked_price_reason=NULL WHERE id=$target_uid");
110 $flash = "Locked price cleared.";
111}
112
113my @u_rows = _query("SELECT * FROM `${DB}`.users WHERE id=$target_uid LIMIT 1");
114unless (@u_rows && $u_rows[0]->{id}) {
115 $db->db_disconnect($dbh);
116 print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit;
117}
118my $usr = $u_rows[0];
119
120my @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())");
121my $bal_cents = ($bal[0] && defined $bal[0]->{c}) ? $bal[0]->{c} + 0 : 0;
122
123my @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");
124
125$db->db_disconnect($dbh);
126
127my $tier_active = ($usr->{plan_tier_override} && length $usr->{plan_tier_override}) ? 1 : 0;
128my $lock_active = (defined $usr->{locked_price_cents_per_seat} && length $usr->{locked_price_cents_per_seat}) ? 1 : 0;
129my $lock_disp = $lock_active ? sprintf('%.2f', $usr->{locked_price_cents_per_seat}/100) : '';
130
131my $disp_name = _h($usr->{display_name} || $usr->{email} || "user $target_uid");
132my $email = _h($usr->{email} // '');
133my $tier = _h($usr->{plan_tier} // '');
134my $status = _h($usr->{account_status} // '');
135my $bal_disp = _fmt_money($bal_cents);
136
137my $ledger_rows = '';
138foreach my $r (@ledger) {
139 my $amt_n = $r->{amount_cents} || 0;
140 my $sign = $amt_n > 0 ? 'erng-pos' : ($amt_n < 0 ? 'erng-neg' : '');
141 my $amt = _fmt_money($amt_n);
142 my $by = _h($r->{by_name} // 'system');
143 my $rsn = _h($r->{reason} // '');
144 my $when = _h($r->{created_at} // '');
145 my $when_ep = $r->{created_at_epoch} || 0;
146 my $when_html = ($when && $when_ep)
147 ? qq~<span class="ts" data-ts="$when_ep" data-fmt="datetime">$when</span>~
148 : $when;
149 my $exp = _h($r->{expires_at} // '');
150 my $exp_ep = $r->{expires_at_epoch} || 0;
151 my $exp_html = ($exp && $exp_ep)
152 ? qq~<span class="ts" data-ts="$exp_ep" data-fmt="datetime">$exp</span>~
153 : $exp;
154 $ledger_rows .= qq~<tr><td>$when_html</td><td class="t-right $sign">$amt</td><td>$by</td><td>$rsn</td><td>$exp_html</td></tr>~;
155}
156$ledger_rows ||= qq~<tr><td colspan="5" class="erng-empty">No credit history yet.</td></tr>~;
157
158my $flash_html = length($flash) ? qq~<div class="erng-flash">${\ _h($flash) }</div>~ : '';
159
160my $tier_active_block = '';
161if ($tier_active) {
162 my $ov = _h($usr->{plan_tier_override});
163 my $ov_exp = $usr->{plan_tier_override_expires_at} ? " &middot; expires " . _h($usr->{plan_tier_override_expires_at}) : '';
164 my $ov_rsn = length($usr->{plan_tier_override_reason} // '') ? "<div class='reason'>" . _h($usr->{plan_tier_override_reason}) . "</div>" : '';
165 $tier_active_block = qq~
166 <div class="erng-active-pill">
167 <strong>Active override:</strong> $ov$ov_exp
168 $ov_rsn
169 <form method="POST" action="/admin_user_billing.cgi" style="display:inline">
170 <input type="hidden" name="act" value="clear_tier_override">
171 <input type="hidden" name="id" value="$target_uid">
172 <button type="submit" class="erng-btn-ghost">Clear override</button>
173 </form>
174 </div>~;
175}
176
177my $lock_active_block = '';
178if ($lock_active) {
179 my $lk_exp = $usr->{locked_price_expires_at} ? " &middot; expires " . _h($usr->{locked_price_expires_at}) : '';
180 my $lk_rsn = length($usr->{locked_price_reason} // '') ? "<div class='reason'>" . _h($usr->{locked_price_reason}) . "</div>" : '';
181 $lock_active_block = qq~
182 <div class="erng-active-pill">
183 <strong>Active locked price:</strong> \$$lock_disp / seat$lk_exp
184 $lk_rsn
185 <form method="POST" action="/admin_user_billing.cgi" style="display:inline">
186 <input type="hidden" name="act" value="clear_locked_price">
187 <input type="hidden" name="id" value="$target_uid">
188 <button type="submit" class="erng-btn-ghost">Clear lock</button>
189 </form>
190 </div>~;
191}
192
193my $body = qq~
194<style>
195.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; }
196.adu-hero h1 { margin: 0 0 6px; font-size: 24px; color: #fff; }
197.adu-hero .sub { color: #8893a4; font-size: 13px; }
198.adu-hero .meta { margin-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; }
199.adu-hero .meta span { color: #cfd6e0; }
200.adu-hero .meta strong { color: #fff; }
201.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; }
202.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; }
203.erng-panel-head { margin-bottom: 14px; }
204.erng-panel-title { font-size: 16px; font-weight: 700; color: #fff; }
205.erng-panel-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
206.erng-pos { color: #4ade80; }
207.erng-neg { color: #f87171; }
208.erng-empty { text-align: center; padding: 24px; color: #7a8392; font-size: 13px; font-style: italic; }
209table.erng-table { width: 100%; border-collapse: collapse; font-size: 13px; }
210table.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; }
211table.erng-table th { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
212.t-right { text-align: right; }
213.adu-form { display: grid; grid-template-columns: 1fr 140px 140px 1fr auto; gap: 10px 12px; align-items: end; }
214.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; }
215.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; }
216.erng-btn { padding: 7px 14px; background: #5aa9ff; color: #0a0f17; border: 0; border-radius: 7px; font-weight: 700; font-size: 13px; cursor: pointer; }
217.erng-btn:hover { background: #7cc1ff; }
218.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; }
219.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; }
220.erng-active-pill .reason { font-size: 12px; color: #8893a4; margin-top: 4px; }
221</style>
222
223<div class="adu-hero">
224 <h1>$disp_name</h1>
225 <p class="sub">Super-admin billing controls for this user.</p>
226 <div class="meta">
227 <span>Email: <strong>$email</strong></span>
228 <span>Plan: <strong>$tier</strong></span>
229 <span>Status: <strong>$status</strong></span>
230 <span>User ID: <strong>$target_uid</strong></span>
231 </div>
232</div>
233
234$flash_html
235
236<div class="erng-panel">
237 <div class="erng-panel-head">
238 <div class="erng-panel-title">Account credit</div>
239 <div class="erng-panel-sub">Current balance: <strong>$bal_disp</strong>. Positive amounts grant credit; negative amounts debit (e.g. reversal).</div>
240 </div>
241 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
242 <input type="hidden" name="act" value="grant_credit">
243 <input type="hidden" name="id" value="$target_uid">
244 <label>Amount (\$) <input type="number" name="amount" step="0.01" placeholder="50.00 or -25.00" required></label>
245 <label>Expires <input type="date" name="expires_at"></label>
246 <label>Reason <input type="text" name="reason" placeholder="Goodwill / refund / promo"></label>
247 <div></div>
248 <div><button type="submit" class="erng-btn">Apply</button></div>
249 </form>
250</div>
251
252<div class="erng-panel">
253 <div class="erng-panel-head">
254 <div class="erng-panel-title">Plan-tier override</div>
255 <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>
256 </div>
257 $tier_active_block
258 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
259 <input type="hidden" name="act" value="set_tier_override">
260 <input type="hidden" name="id" value="$target_uid">
261 <label>Tier
262 <select name="tier" required>
263 <option value="">--</option>
264 <option value="free">free</option>
265 <option value="pro">pro</option>
266 <option value="business">business</option>
267 </select>
268 </label>
269 <label>Expires <input type="date" name="expires_at"></label>
270 <label></label>
271 <label>Reason <input type="text" name="reason" placeholder="Why this override?"></label>
272 <div><button type="submit" class="erng-btn">Set</button></div>
273 </form>
274</div>
275
276<div class="erng-panel">
277 <div class="erng-panel-head">
278 <div class="erng-panel-title">Locked per-seat price</div>
279 <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>
280 </div>
281 $lock_active_block
282 <form method="POST" action="/admin_user_billing.cgi" class="adu-form">
283 <input type="hidden" name="act" value="set_locked_price">
284 <input type="hidden" name="id" value="$target_uid">
285 <label>Price / seat (\$) <input type="number" name="amount" step="0.01" placeholder="9.00" required></label>
286 <label>Expires <input type="date" name="expires_at"></label>
287 <label></label>
288 <label>Reason <input type="text" name="reason" placeholder="Why this lock?"></label>
289 <div><button type="submit" class="erng-btn">Set</button></div>
290 </form>
291</div>
292
293<div class="erng-panel">
294 <div class="erng-panel-head">
295 <div class="erng-panel-title">Credit history</div>
296 <div class="erng-panel-sub">Every grant and debit on this user's ledger.</div>
297 </div>
298 <table class="erng-table">
299 <thead><tr><th>When</th><th class="t-right">Amount</th><th>By</th><th>Reason</th><th>Expires</th></tr></thead>
300 <tbody>$ledger_rows</tbody>
301 </table>
302</div>
303~;
304
305print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n";
306$wrap->render({ userinfo => $u, page_key => 'admin_users', title => "Billing &mdash; $disp_name", body => $body });