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

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

added on local at 2026-07-01 15:02:19

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