Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/admin_user_billing.cgi
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
to 167370d9505f
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 | #====================================================================== | |
| 11 | use strict; | |
| 12 | use warnings; | |
| 13 | ||
| 14 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 15 | use CGI; | |
| 16 | use MODS::Template; | |
| 17 | use MODS::Login; | |
| 18 | use MODS::ShopCart::Wrapper; | |
| 19 | use MODS::DBConnect; | |
| 20 | use MODS::ShopCart::Config; | |
| 21 | ||
| 22 | # Inline helpers (sister sites carry no MODS::*::PM with these) | |
| 23 | sub _safe_int { my $v = shift // ''; $v =~ s/[^0-9\-]//g; return $v ? $v + 0 : 0; } | |
| 24 | sub _safe_cents { | |
| 25 | my $v = shift // ''; $v =~ s/[^0-9.\-]//g; | |
| 26 | return int($v * 100 + ($v < 0 ? -0.5 : 0.5)); | |
| 27 | } | |
| 28 | sub _safe_date { my $v = shift // ''; return ($v =~ /^(\d{4}-\d{2}-\d{2})$/) ? $1 : ''; } | |
| 29 | sub _sql_q { my $s = shift // ''; $s =~ s/\\/\\\\/g; $s =~ s/'/''/g; return $s; } | |
| 30 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; return $s; } | |
| 31 | sub _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 | ||
| 38 | my $q = CGI->new; | |
| 39 | my $form = $q->Vars; | |
| 40 | my $auth = MODS::Login->new; | |
| 41 | my $wrap = MODS::ShopCart::Wrapper->new; | |
| 42 | my $db = MODS::DBConnect->new; | |
| 43 | my $cfg = MODS::ShopCart::Config->new; | |
| 44 | my $DB = $cfg->settings('database_name'); | |
| 45 | ||
| 46 | my $u = $auth->login_verify(); | |
| 47 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 48 | unless ($u->{is_super_admin}) { print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; exit; } | |
| 49 | ||
| 50 | my $target_uid = _safe_int($form->{id}); | |
| 51 | unless ($target_uid) { print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit; } | |
| 52 | my $admin_uid = ($u->{user_id} || 0) + 0; | |
| 53 | ||
| 54 | my $dbh = $db->db_connect(); | |
| 55 | ||
| 56 | sub _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 | } | |
| 64 | sub _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 | ||
| 75 | my $flash = ''; | |
| 76 | my $act = $form->{act} // ''; | |
| 77 | ||
| 78 | if ($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 | ||
| 113 | my @u_rows = _query("SELECT * FROM `${DB}`.users WHERE id=$target_uid LIMIT 1"); | |
| 114 | unless (@u_rows && $u_rows[0]->{id}) { | |
| 115 | $db->db_disconnect($dbh); | |
| 116 | print "Status: 302 Found\nLocation: /admin_users.cgi\n\n"; exit; | |
| 117 | } | |
| 118 | my $usr = $u_rows[0]; | |
| 119 | ||
| 120 | my @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())"); | |
| 121 | my $bal_cents = ($bal[0] && defined $bal[0]->{c}) ? $bal[0]->{c} + 0 : 0; | |
| 122 | ||
| 123 | my @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 | ||
| 127 | my $tier_active = ($usr->{plan_tier_override} && length $usr->{plan_tier_override}) ? 1 : 0; | |
| 128 | my $lock_active = (defined $usr->{locked_price_cents_per_seat} && length $usr->{locked_price_cents_per_seat}) ? 1 : 0; | |
| 129 | my $lock_disp = $lock_active ? sprintf('%.2f', $usr->{locked_price_cents_per_seat}/100) : ''; | |
| 130 | ||
| 131 | my $disp_name = _h($usr->{display_name} || $usr->{email} || "user $target_uid"); | |
| 132 | my $email = _h($usr->{email} // ''); | |
| 133 | my $tier = _h($usr->{plan_tier} // ''); | |
| 134 | my $status = _h($usr->{account_status} // ''); | |
| 135 | my $bal_disp = _fmt_money($bal_cents); | |
| 136 | ||
| 137 | my $ledger_rows = ''; | |
| 138 | foreach 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 | ||
| 158 | my $flash_html = length($flash) ? qq~<div class="erng-flash">${\ _h($flash) }</div>~ : ''; | |
| 159 | ||
| 160 | my $tier_active_block = ''; | |
| 161 | if ($tier_active) { | |
| 162 | my $ov = _h($usr->{plan_tier_override}); | |
| 163 | my $ov_exp = $usr->{plan_tier_override_expires_at} ? " · 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 | ||
| 177 | my $lock_active_block = ''; | |
| 178 | if ($lock_active) { | |
| 179 | my $lk_exp = $usr->{locked_price_expires_at} ? " · 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 | ||
| 193 | my $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; } | |
| 209 | table.erng-table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 210 | table.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; } | |
| 211 | table.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 — 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 | ||
| 305 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n"; | |
| 306 | $wrap->render({ userinfo => $u, page_key => 'admin_users', title => "Billing — $disp_name", body => $body }); |