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

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

added on local at 2026-07-01 22:09:19

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