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

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

added on local at 2026-07-01 16:00:55

Added
+446
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 9f393a93eef1
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# ABForge - Admin Packages (plans + pricing + entitlements)
4#
5# Company-staff-only console for editing the catalogue of billing plans.
6# Every change here flows out to pricing.cgi, the index.cgi pricing
7# block, and the per-feature gate on profile.cgi via plan_features.
8#
9# Views:
10# list = catalogue grid + create form (?new=1 opens the create form)
11# edit = full edit form for a single plan (?edit_id=N)
12#
13# Saves are POSTed to /admin_packages_action.cgi which redirects back
14# here on success.
15#======================================================================
16use strict;
17use warnings;
18
19use lib '/var/www/vhosts/abforge.com/httpdocs';
20use CGI;
21use MODS::Template;
22use MODS::DBConnect;
23use MODS::Login;
24use MODS::ABForge::Config;
25use MODS::ABForge::Wrapper;
26use MODS::ABForge::Admin;
27use MODS::ABForge::Billing;
28
29my $q = CGI->new;
30my $form = $q->Vars;
31my $auth = MODS::Login->new;
32my $wrap = MODS::ABForge::Wrapper->new;
33my $tfile = MODS::Template->new;
34my $db = MODS::DBConnect->new;
35my $cfg = MODS::ABForge::Config->new;
36my $admin = MODS::ABForge::Admin->new;
37my $bill = MODS::ABForge::Billing->new;
38my $DB = $cfg->settings('database_name');
39
40$|=1;
41my $userinfo = $auth->login_verify();
42if (!$userinfo) {
43 print "Status: 302 Found\nLocation: /login.cgi\n\n";
44 exit;
45}
46
47my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_packages';
48
49if ($entry eq 'admin_packages_action') {
50 $admin->require_admin($userinfo);
51 require MODS::ABForge::Permissions;
52 # Match the gate on admin_packages.cgi -- platform pricing is owner-only.
53 MODS::ABForge::Permissions->new->require_super_admin($userinfo);
54 _handle_action($q, $form);
55 exit;
56}
57
58$admin->require_admin($userinfo);
59require MODS::ABForge::Permissions;
60# Plan pricing is global to the platform -- super-admin only. Manager
61# role can still issue refunds and grant credit per-user (those go
62# through admin_billing_action.cgi which keeps the manager-level
63# admin_edit_billing ptag), but nobody except the owner sets prices.
64MODS::ABForge::Permissions->new->require_super_admin($userinfo);
65
66my $view = ($form->{new} && $form->{new} eq '1') ? 'new'
67 : ($form->{edit_id} && $form->{edit_id} =~ /^\d+$/) ? 'edit'
68 : 'list';
69my $edit_id = $form->{edit_id} || '';
70$edit_id =~ s/[^0-9]//g;
71
72my $flash_msg = '';
73my $flash_kind = 'ok';
74if ($form->{msg}) {
75 my %ok = (
76 saved => 'Plan saved.',
77 created => 'Plan created.',
78 active => 'Plan reactivated.',
79 retired => 'Plan retired -- it stays in the catalogue for historical invoices but no longer shows in pricing.',
80 );
81 if ($form->{msg} =~ /^err:(.+)$/) {
82 my $detail = $1;
83 $detail =~ s/[^A-Za-z0-9 .,;:_\-\$()%']//g;
84 $flash_msg = "Couldn't save: $detail";
85 $flash_kind = 'danger';
86 } elsif ($ok{ $form->{msg} }) {
87 $flash_msg = $ok{ $form->{msg} };
88 }
89}
90
91my $dbh = $db->db_connect();
92my $schema_ready = $bill->schema_ready($db, $dbh, $DB);
93my $columns_ready = $schema_ready ? $bill->plan_columns_ready($db, $dbh, $DB) : 0;
94my $entitle_ready = $schema_ready ? $bill->plan_features_ready($db, $dbh, $DB) : 0;
95my $migration_pending = ($schema_ready && (!$columns_ready || !$entitle_ready)) ? 1 : 0;
96
97my @catalog = $bill->feature_catalog;
98
99# ---- LIST view: every plan (active + retired) grouped by tier ----
100my @plans_for_list;
101if ($schema_ready) {
102 foreach my $p ($bill->list_plans($db, $dbh, $DB, 1)) {
103 my $features_set = $entitle_ready
104 ? $bill->plan_feature_set($db, $dbh, $DB, $p->{id})
105 : {};
106 my @fset_labels;
107 foreach my $f (@catalog) {
108 push @fset_labels, $f->{name} if $features_set->{ $f->{key} };
109 }
110 # Compute the effective yearly price from whatever mode this
111 # plan is using so the list view shows both monthly + yearly.
112 my $yearly_cents = $bill->compute_yearly_price_cents($p);
113 my $yearly_label = '';
114 if ($yearly_cents > 0) {
115 $yearly_label = $bill->fmt_money($yearly_cents, $p->{currency});
116 } elsif (($p->{price_cents} || 0) > 0) {
117 $yearly_label = 'No yearly';
118 }
119
120 push @plans_for_list, {
121 id => $p->{id},
122 tier => $p->{tier},
123 tier_label => ucfirst($p->{tier} || ''),
124 display_name => $p->{display_name} || '',
125 tagline => $p->{tagline} || '',
126 cta_label => $p->{cta_label} || '',
127 price_display => $bill->fmt_money($p->{price_cents} || 0, $p->{currency}),
128 price_cents => $p->{price_cents} || 0,
129 yearly_display => $yearly_label,
130 yearly_cents => $yearly_cents,
131 yearly_mode => $p->{yearly_mode} || 'none',
132 has_yearly => $yearly_cents > 0 ? 1 : 0,
133 sort_order => $p->{sort_order} || 0,
134 is_active => $p->{is_active} ? 1 : 0,
135 is_inactive => $p->{is_active} ? 0 : 1,
136 is_featured => $p->{is_featured} ? 1 : 0,
137 entitled_summary => (@fset_labels ? join(', ', @fset_labels) : 'No features assigned'),
138 entitled_count => scalar(@fset_labels),
139 };
140 }
141}
142
143# ---- EDIT / NEW view ---------------------------------------------------
144my %edit_row;
145my @edit_features;
146if ($view eq 'edit' && $edit_id) {
147 my $p = $bill->plan_by_id($db, $dbh, $DB, $edit_id);
148 if ($p && $p->{id}) {
149 my $set = $entitle_ready
150 ? $bill->plan_feature_set($db, $dbh, $DB, $p->{id})
151 : {};
152 my $ym = $p->{yearly_mode} || 'none';
153 %edit_row = (
154 id => $p->{id},
155 tier => $p->{tier} || '',
156 display_name => _h_attr($p->{display_name}),
157 tagline => _h_attr($p->{tagline}),
158 cta_label => _h_attr($p->{cta_label}),
159 features_raw => _h_textarea($p->{features}),
160 price_cents => $p->{price_cents} || 0,
161 price_dollars => sprintf('%.2f', ($p->{price_cents} || 0) / 100),
162 currency => $p->{currency} || 'USD',
163 sort_order => defined $p->{sort_order} ? $p->{sort_order} : 0,
164 is_active => $p->{is_active} ? 1 : 0,
165 is_featured => $p->{is_featured} ? 1 : 0,
166 is_active_checked => $p->{is_active} ? 'checked' : '',
167 is_featured_checked => $p->{is_featured} ? 'checked' : '',
168 # Yearly-pricing inputs (single row per tier, replaces the
169 # old monthly+annual duplicate plans). Three modes:
170 # none - tier offers monthly only (e.g. Free)
171 # percent_off - X% off the monthly * 12 total
172 # dollars_off - Subtract Y dollars from monthly * 12
173 # explicit - Type an exact yearly price
174 yearly_mode => $ym,
175 yearly_mode_none_sel => ($ym eq 'none') ? ' selected' : '',
176 yearly_mode_percent_sel => ($ym eq 'percent_off') ? ' selected' : '',
177 yearly_mode_dollars_sel => ($ym eq 'dollars_off') ? ' selected' : '',
178 yearly_mode_explicit_sel=> ($ym eq 'explicit') ? ' selected' : '',
179 yearly_pct_off => int($p->{yearly_pct_off} || 0),
180 yearly_off_dollars => sprintf('%.2f', int($p->{yearly_off_cents} || 0) / 100),
181 yearly_price_dollars => sprintf('%.2f', int($p->{yearly_price_cents} || 0) / 100),
182 yearly_computed => $bill->fmt_money($bill->compute_yearly_price_cents($p), $p->{currency} || 'USD'),
183 );
184 foreach my $f (@catalog) {
185 push @edit_features, {
186 key => $f->{key},
187 name => $f->{name},
188 blurb => $f->{blurb},
189 checked => $set->{ $f->{key} } ? 'checked' : '',
190 included => $set->{ $f->{key} } ? 1 : 0,
191 };
192 }
193 } else {
194 $view = 'list'; # bad id -> fall back
195 }
196}
197if ($view eq 'new') {
198 %edit_row = (
199 id => 0,
200 tier => '',
201 display_name => '',
202 tagline => '',
203 cta_label => 'Start free trial',
204 features_raw => '',
205 price_cents => 0,
206 price_dollars => '0.00',
207 currency => 'USD',
208 sort_order => (scalar(@plans_for_list) * 10) + 10,
209 is_active => 1,
210 is_featured => 0,
211 is_active_checked => 'checked',
212 is_featured_checked => '',
213 yearly_mode => 'percent_off',
214 yearly_mode_none_sel => '',
215 yearly_mode_percent_sel => ' selected',
216 yearly_mode_dollars_sel => '',
217 yearly_mode_explicit_sel=> '',
218 yearly_pct_off => 17, # ~"2 months free" default
219 yearly_off_dollars => '0.00',
220 yearly_price_dollars => '0.00',
221 yearly_computed => '$0.00',
222 );
223 foreach my $f (@catalog) {
224 push @edit_features, {
225 key => $f->{key},
226 name => $f->{name},
227 blurb => $f->{blurb},
228 checked => '',
229 included => 0,
230 };
231 }
232}
233
234# Feature catalog block shared by both list (column header hint) and
235# the edit form. We also pre-compute the list of plans that currently
236# include each feature so the click-to-detail modal can show "Included
237# in: Starter, Pro, Studio" without doing a second round-trip.
238my @feature_catalog_view;
239foreach my $f (@catalog) {
240 my @plans_with;
241 if ($entitle_ready) {
242 foreach my $p (@plans_for_list) {
243 my $set = $bill->plan_feature_set($db, $dbh, $DB, $p->{id});
244 push @plans_with, $p->{display_name} if $set->{ $f->{key} };
245 }
246 }
247 push @feature_catalog_view, {
248 key => $f->{key},
249 name => $f->{name},
250 blurb => $f->{blurb},
251 details => $f->{details} || '',
252 enforced_in => $f->{enforced_in} || '',
253 plans_included => (@plans_with ? join(', ', @plans_with) : 'No plans currently include this feature.'),
254 has_plans => scalar(@plans_with) ? 1 : 0,
255 };
256}
257
258$db->db_disconnect($dbh);
259
260my $tvars = {
261 user_id => $userinfo->{user_id},
262 is_view_list => ($view eq 'list') ? 1 : 0,
263 is_view_edit => ($view eq 'edit') ? 1 : 0,
264 is_view_new => ($view eq 'new') ? 1 : 0,
265 is_view_form => ($view ne 'list') ? 1 : 0,
266 schema_ready => $schema_ready ? 1 : 0,
267 schema_missing => $schema_ready ? 0 : 1,
268 migration_pending => $migration_pending,
269 flash_msg => $flash_msg,
270 has_flash => $flash_msg ? 1 : 0,
271 flash_kind => $flash_kind,
272 plan_count => scalar(@plans_for_list),
273 plans => \@plans_for_list,
274 has_plans => scalar(@plans_for_list) ? 1 : 0,
275 feature_catalog => \@feature_catalog_view,
276 edit_features => \@edit_features,
277 %edit_row,
278};
279
280print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
281
282my $body = join('', $tfile->template('abforge_admin_packages.html', $tvars, $userinfo));
283
284$wrap->render({
285 userinfo => $userinfo,
286 page_key => 'admin_packages',
287 title => 'Pricing & Promotions',
288 body => $body,
289});
290
291#----------------------------------------------------------------------
292sub _h_attr {
293 my $s = shift; $s = '' unless defined $s;
294 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
295 return $s;
296}
297sub _h_textarea {
298 my $s = shift; $s = '' unless defined $s;
299 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
300 return $s;
301}
302
303#======================================================================
304# admin_packages_action entry: POST handler
305#
306# Actions: create, save, retire, restore
307# Every path redirects back to the list (or the edit form on failure)
308# with a flash key.
309#======================================================================
310sub _handle_action {
311 my ($q, $form) = @_;
312
313 my $action = $form->{action} || '';
314
315 my $dbh = $db->db_connect();
316 unless ($bill->schema_ready($db, $dbh, $DB)) {
317 $db->db_disconnect($dbh);
318 _act_err('billing schema missing -- run the migration first');
319 return;
320 }
321 unless ($bill->plan_columns_ready($db, $dbh, $DB)) {
322 $db->db_disconnect($dbh);
323 _act_err('plan columns not migrated -- run the Packages migration');
324 return;
325 }
326
327 if ($action eq 'create') {
328 my @included = $q->multi_param('feature_keys');
329 my ($new_id, $err) = $bill->create_plan($db, $dbh, $DB, _act_form_to_plan_args($form));
330 if (!$new_id) {
331 $db->db_disconnect($dbh);
332 _act_err($err || 'create failed', '/admin_packages.cgi?new=1');
333 return;
334 }
335 $bill->set_plan_features($db, $dbh, $DB, $new_id, \@included)
336 if $bill->plan_features_ready($db, $dbh, $DB);
337 $db->db_disconnect($dbh);
338 _act_back('/admin_packages.cgi?msg=created');
339 return;
340 }
341 elsif ($action eq 'save') {
342 my $plan_id = $form->{plan_id} || '';
343 $plan_id =~ s/[^0-9]//g;
344 unless ($plan_id) {
345 $db->db_disconnect($dbh);
346 _act_err('missing plan id');
347 return;
348 }
349 my @included = $q->multi_param('feature_keys');
350 my ($id, $err) = $bill->update_plan($db, $dbh, $DB, $plan_id, _act_form_to_plan_args($form));
351 if (!$id) {
352 $db->db_disconnect($dbh);
353 _act_err($err || 'save failed', "/admin_packages.cgi?edit_id=$plan_id");
354 return;
355 }
356 $bill->set_plan_features($db, $dbh, $DB, $id, \@included)
357 if $bill->plan_features_ready($db, $dbh, $DB);
358 $db->db_disconnect($dbh);
359 _act_back('/admin_packages.cgi?msg=saved');
360 return;
361 }
362 elsif ($action eq 'retire') {
363 my $plan_id = $form->{plan_id} || '';
364 $plan_id =~ s/[^0-9]//g;
365 if ($plan_id) {
366 $bill->deactivate_plan($db, $dbh, $DB, $plan_id);
367 }
368 $db->db_disconnect($dbh);
369 _act_back('/admin_packages.cgi?msg=retired');
370 return;
371 }
372 elsif ($action eq 'restore') {
373 my $plan_id = $form->{plan_id} || '';
374 $plan_id =~ s/[^0-9]//g;
375 if ($plan_id) {
376 $bill->activate_plan($db, $dbh, $DB, $plan_id);
377 }
378 $db->db_disconnect($dbh);
379 _act_back('/admin_packages.cgi?msg=active');
380 return;
381 }
382 else {
383 $db->db_disconnect($dbh);
384 _act_back('/admin_packages.cgi');
385 return;
386 }
387}
388
389sub _act_back {
390 my ($target) = @_;
391 $target ||= '/admin_packages.cgi';
392 print "Status: 302 Found\nLocation: $target\n\n";
393}
394sub _act_err {
395 my ($msg, $back_to) = @_;
396 $msg =~ s/[^A-Za-z0-9 .,;:_\-\$()%']//g;
397 _act_back(($back_to || '/admin_packages.cgi') . '?msg=err:' . $msg);
398}
399
400#----------------------------------------------------------------------
401# Convert form fields into the kwargs Billing->{create,update}_plan
402# expect. Splits price (entered in dollars) into cents.
403sub _act_form_to_plan_args {
404 my ($f) = @_;
405 my $price_dollars = defined $f->{price_dollars} ? $f->{price_dollars} : '0';
406 $price_dollars =~ s/[^0-9.]//g;
407 my $price_cents = int(($price_dollars + 0) * 100 + 0.5);
408
409 # Yearly pricing: three modes, only one of the three inputs is read.
410 my %yearly_ok = map { $_ => 1 } qw(none percent_off dollars_off explicit);
411 my $yearly_mode = $f->{yearly_mode} || 'none';
412 $yearly_mode = 'none' unless $yearly_ok{$yearly_mode};
413
414 my $yearly_pct_off = int($f->{yearly_pct_off} || 0);
415 $yearly_pct_off = 0 if $yearly_pct_off < 0;
416 $yearly_pct_off = 90 if $yearly_pct_off > 90;
417
418 my $yearly_off_dollars = defined $f->{yearly_off_dollars} ? $f->{yearly_off_dollars} : '0';
419 $yearly_off_dollars =~ s/[^0-9.]//g;
420 my $yearly_off_cents = int(($yearly_off_dollars + 0) * 100 + 0.5);
421
422 my $yearly_price_dollars = defined $f->{yearly_price_dollars} ? $f->{yearly_price_dollars} : '0';
423 $yearly_price_dollars =~ s/[^0-9.]//g;
424 my $yearly_price_cents = int(($yearly_price_dollars + 0) * 100 + 0.5);
425
426 return (
427 tier => $f->{tier},
428 # cadence is always 'monthly' on the row -- the row is the
429 # canonical tier definition; the yearly_mode fields drive
430 # the annual price.
431 cadence => 'monthly',
432 display_name => $f->{display_name},
433 tagline => $f->{tagline},
434 cta_label => $f->{cta_label},
435 features => $f->{features_raw},
436 price_cents => $price_cents,
437 currency => $f->{currency},
438 sort_order => $f->{sort_order},
439 is_featured => ($f->{is_featured} ? 1 : 0),
440 is_active => ($f->{is_active} ? 1 : 0),
441 yearly_mode => $yearly_mode,
442 yearly_pct_off => $yearly_pct_off,
443 yearly_off_cents => $yearly_off_cents,
444 yearly_price_cents => $yearly_price_cents,
445 );
446}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help