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

O Operator
Diff

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

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

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