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

O Operator
Diff

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

added on local at 2026-07-11 18:31:44

Added
+496
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 14e35f3b5efd
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 Promotions
4#
5# Platform-wide promotions (scope='platform'). These can target any
6# subscriber on any plan -- used for new-customer acquisition and
7# platform-wide marketing pushes (signup discounts, holiday sales,
8# Black Friday flash sales, etc.).
9#
10# Super-admin-gated via MODS::ContactForge::Permissions->require_super_admin
11# to match admin_packages.cgi -- pricing & promotions live together.
12#
13# Consolidates admin_promotion_action.cgi via SCRIPT_NAME dispatch.
14#======================================================================
15use strict;
16use warnings;
17
18use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
19use CGI;
20use MODS::Template;
21use MODS::DBConnect;
22use MODS::Login;
23use MODS::ContactForge::Config;
24use MODS::ContactForge::Wrapper;
25use MODS::ContactForge::Admin;
26use MODS::ContactForge::Promotions;
27use MODS::ContactForge::Billing;
28
29my $q = CGI->new;
30my $form = $q->Vars;
31my $auth = MODS::Login->new;
32my $wrap = MODS::ContactForge::Wrapper->new;
33my $tfile = MODS::Template->new;
34my $db = MODS::DBConnect->new;
35my $cfg = MODS::ContactForge::Config->new;
36my $admin = MODS::ContactForge::Admin->new;
37my $promo = MODS::ContactForge::Promotions->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$admin->require_admin($userinfo);
47require MODS::ContactForge::Permissions;
48MODS::ContactForge::Permissions->new->require_super_admin($userinfo);
49
50my $admin_id = $userinfo->{user_id};
51$admin_id =~ s/[^0-9]//g;
52
53my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_promotions';
54
55if ($entry eq 'admin_promotion_action') {
56 _handle_action();
57 exit;
58}
59
60my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
61
62my $view = ($form->{new} && $form->{new} eq '1') ? 'new'
63 : ($form->{edit_id} && $form->{edit_id} =~ /^\d+$/) ? 'edit'
64 : 'list';
65my $edit_id = ($form->{edit_id} || '');
66$edit_id =~ s/[^0-9]//g;
67
68my $dbh = $db->db_connect();
69my $schema_ready = $promo->schema_ready($db, $dbh, $DB);
70
71my @rows_raw = $schema_ready ? $promo->list_for_admin($db, $dbh, $DB, 200) : ();
72
73my @rows;
74my $cnt_active = 0; my $cnt_scheduled = 0; my $cnt_paused = 0; my $cnt_expired = 0;
75my $sum_redemptions = 0; my $sum_discount_cents = 0;
76foreach my $r (@rows_raw) {
77 my $effective = $promo->effective_status($r);
78 $cnt_active++ if $effective eq 'active';
79 $cnt_scheduled++ if $effective eq 'scheduled';
80 $cnt_paused++ if $effective eq 'paused';
81 $cnt_expired++ if $effective eq 'expired' || $effective eq 'exhausted';
82 $sum_redemptions += $r->{current_redemptions} || 0;
83 $sum_discount_cents += $promo->total_discount_given_cents($db, $dbh, $DB, $r->{id});
84
85 push @rows, _row_for_template($r, $promo, $db, $dbh, $DB);
86}
87
88my %edit_row;
89if ($view eq 'edit' && $edit_id) {
90 my $e = $promo->get_for_admin($db, $dbh, $DB, $edit_id);
91 if ($e && $e->{id}) {
92 %edit_row = _row_for_edit_form($e);
93 my @eligible = $promo->eligible_plan_ids($db, $dbh, $DB, $e->{id});
94 $edit_row{eligible_plan_ids_csv} = join(',', @eligible);
95 $edit_row{applies_all_plans} = scalar(@eligible) ? 0 : 1;
96 $edit_row{applies_specific} = scalar(@eligible) ? 1 : 0;
97 } else {
98 $view = 'list';
99 }
100}
101
102# Paid plans list (Free excluded -- nothing to discount from $0) for the
103# "Applies to" checklist on the create/edit form.
104my @plan_options;
105{
106 my $bill = MODS::ContactForge::Billing->new;
107 if ($bill->schema_ready($db, $dbh, $DB)) {
108 foreach my $p ($bill->list_plans($db, $dbh, $DB)) {
109 next unless ($p->{price_cents} || 0) > 0;
110 my $set = $edit_row{eligible_plan_ids_csv} || '';
111 my $checked = ($view eq 'edit' && $set =~ /(^|,)$p->{id}(,|$)/) ? 'checked' : '';
112 push @plan_options, {
113 id => $p->{id},
114 tier_label => ucfirst($p->{tier} || ''),
115 display_name => $p->{display_name} || ucfirst($p->{tier} || ''),
116 price_display => $bill->fmt_money($p->{price_cents} || 0, $p->{currency}),
117 checked => $checked,
118 };
119 }
120 }
121}
122
123my %flash;
124if (($form->{ok} || '') eq 'created') { %flash = (kind=>'ok', msg=>'Platform promotion created.'); }
125elsif (($form->{ok} || '') eq 'updated') { %flash = (kind=>'ok', msg=>'Platform promotion updated.'); }
126elsif (($form->{ok} || '') eq 'deleted') { %flash = (kind=>'ok', msg=>'Platform promotion deleted.'); }
127elsif (($form->{ok} || '') eq 'activated') { %flash = (kind=>'ok', msg=>'Promotion is now active across the platform.'); }
128elsif (($form->{ok} || '') eq 'paused') { %flash = (kind=>'ok', msg=>'Promotion paused.'); }
129elsif (($form->{err} || '') eq 'bad_input') { %flash = (kind=>'err', msg=>'Required fields missing.'); }
130elsif (($form->{err} || '') eq 'denied') { %flash = (kind=>'err', msg=>'Action not permitted.'); }
131
132$db->db_disconnect($dbh);
133
134my $tvars = {
135 user_id => $admin_id,
136
137 is_list => ($view eq 'list') ? 1 : 0,
138 is_new => ($view eq 'new') ? 1 : 0,
139 is_edit => ($view eq 'edit') ? 1 : 0,
140
141 rows => \@rows,
142 has_rows => scalar(@rows) ? 1 : 0,
143 cnt_active => $cnt_active,
144 cnt_scheduled => $cnt_scheduled,
145 cnt_paused => $cnt_paused,
146 cnt_expired => $cnt_expired,
147 cnt_total => scalar(@rows),
148 sum_redemptions => $sum_redemptions,
149 sum_discount_display => '$' . sprintf('%.2f', $sum_discount_cents / 100),
150
151 edit => \%edit_row,
152 has_edit => %edit_row ? 1 : 0,
153 plan_options => \@plan_options,
154 has_plan_options => scalar(@plan_options) ? 1 : 0,
155
156 schema_ready => $schema_ready ? 1 : 0,
157 schema_missing => $schema_ready ? 0 : 1,
158
159 flash_kind => $flash{kind} || '',
160 flash_msg => $flash{msg} || '',
161 has_flash => %flash ? 1 : 0,
162
163 tutorial_mode => $tutorial_mode,
164 not_tutorial_mode => $tutorial_mode ? 0 : 1,
165};
166
167# Tutorial sample for the list view.
168if ($tutorial_mode && $view eq 'list') {
169 $tvars->{rows} = [
170 { id=>1, name=>'New customer 20 percent off first month', kind_label=>'Coupon', is_coupon=>1, is_sale=>0, is_bundle=>0, is_flash=>0,
171 discount_label=>'20% off', code=>'NEWBIE20', code_present=>1, public_slug=>'newbie20', slug_present=>1,
172 status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0,
173 starts_at=>'2026-01-01 00:00:00', ends_at=>'', has_window=>0,
174 current_redemptions=>418, max_redemptions=>0, has_cap=>0, cap_label=>'418 used',
175 discount_given_display=>'$4,180.00', new_customers_only=>1,
176 edit_url=>'/admin_promotions.cgi?edit_id=1', tutorial_demo=>1 },
177 { id=>2, name=>'Summer 15 off all plans', kind_label=>'Sale', is_coupon=>0, is_sale=>1, is_bundle=>0, is_flash=>0,
178 discount_label=>'15% off', code=>'', code_present=>0, public_slug=>'summer-sale', slug_present=>1,
179 status=>'scheduled', status_label=>'Scheduled', is_active=>0, is_paused=>0, is_scheduled=>1, is_expired=>0, is_draft=>0,
180 starts_at=>'2026-06-21 00:00:00', ends_at=>'2026-07-04 23:59:00', has_window=>1,
181 current_redemptions=>0, max_redemptions=>0, has_cap=>0, cap_label=>'0 used',
182 discount_given_display=>'$0.00', new_customers_only=>0,
183 edit_url=>'/admin_promotions.cgi?edit_id=2', tutorial_demo=>1 },
184 { id=>3, name=>'Black Friday 30 off annual', kind_label=>'Flash sale', is_coupon=>0, is_sale=>0, is_bundle=>0, is_flash=>1,
185 discount_label=>'30% off', code=>'BLACKFRI', code_present=>1, public_slug=>'black-friday', slug_present=>1,
186 status=>'scheduled', status_label=>'Scheduled', is_active=>0, is_paused=>0, is_scheduled=>1, is_expired=>0, is_draft=>0,
187 starts_at=>'2026-11-27 00:00:00', ends_at=>'2026-11-29 23:59:00', has_window=>1,
188 current_redemptions=>0, max_redemptions=>5000, has_cap=>1, cap_label=>'0 of 5000 used',
189 discount_given_display=>'$0.00', new_customers_only=>0,
190 edit_url=>'/admin_promotions.cgi?edit_id=3', tutorial_demo=>1 },
191 ];
192 foreach my $r (@{ $tvars->{rows} }) {
193 $r->{starts_epoch} = _ap_ts_epoch($r->{starts_at});
194 $r->{ends_epoch} = _ap_ts_epoch($r->{ends_at});
195 }
196 $tvars->{has_rows} = 1;
197 $tvars->{cnt_active} = 1;
198 $tvars->{cnt_scheduled} = 2;
199 $tvars->{cnt_paused} = 0;
200 $tvars->{cnt_expired} = 0;
201 $tvars->{cnt_total} = 3;
202 $tvars->{sum_redemptions} = 418;
203 $tvars->{sum_discount_display} = '$4,180.00';
204 $tvars->{schema_ready} = 1;
205 $tvars->{schema_missing} = 0;
206}
207
208print "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";
209
210my $body = join('', $tfile->template('cf_admin_promotions.html', $tvars, $userinfo));
211
212$wrap->render({
213 userinfo => $userinfo,
214 page_key => 'admin_packages',
215 title => 'Pricing & Promotions',
216 body => $body,
217});
218
219# ----------------------------------------------------------------- helpers
220sub _row_for_template {
221 my ($r, $promo, $db, $dbh, $DB) = @_;
222 my $eff = $promo->effective_status($r);
223 return {
224 id => $r->{id},
225 name => _h($r->{name} || ''),
226 kind_label => ucfirst($r->{kind} || ''),
227 is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0,
228 is_sale => ($r->{kind} eq 'sale') ? 1 : 0,
229 is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0,
230 is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0,
231 discount_label => $promo->discount_label($r),
232 code => _h($r->{code} || ''),
233 code_present => ($r->{code} && $r->{code} ne '') ? 1 : 0,
234 public_slug => _h($r->{public_slug} || ''),
235 slug_present => ($r->{public_slug} && $r->{public_slug} ne '') ? 1 : 0,
236 status => $eff,
237 status_label => $promo->status_label($eff),
238 is_active => $eff eq 'active' ? 1 : 0,
239 is_paused => $eff eq 'paused' ? 1 : 0,
240 is_scheduled => $eff eq 'scheduled' ? 1 : 0,
241 is_expired => ($eff eq 'expired' || $eff eq 'exhausted') ? 1 : 0,
242 is_draft => $eff eq 'draft' ? 1 : 0,
243 starts_at => $r->{starts_at} || '',
244 ends_at => $r->{ends_at} || '',
245 starts_epoch => _ap_ts_epoch($r->{starts_at}),
246 ends_epoch => _ap_ts_epoch($r->{ends_at}),
247 has_window => (($r->{starts_at} || $r->{ends_at}) ? 1 : 0),
248 current_redemptions => $r->{current_redemptions} || 0,
249 max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : 0,
250 has_cap => (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) ? 1 : 0,
251 cap_label => _cap_label($r),
252 discount_given_display => '$' . sprintf('%.2f', ($promo->total_discount_given_cents($db, $dbh, $DB, $r->{id}) || 0) / 100),
253 new_customers_only => $r->{new_customers_only} ? 1 : 0,
254 edit_url => '/admin_promotions.cgi?edit_id=' . $r->{id},
255 };
256}
257
258sub _row_for_edit_form {
259 my ($r) = @_;
260 my $pct = ($r->{discount_value_bp} || 0) / 100;
261 my $amt = ($r->{discount_value_cents} || 0) / 100;
262 my $min = ($r->{minimum_order_cents} || 0) / 100;
263 my $tk = $r->{target_kind} || 'plan';
264 return (
265 id => $r->{id},
266 name => _h($r->{name} || ''),
267 description => _h($r->{description} || ''),
268 kind => $r->{kind},
269 is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0,
270 is_sale => ($r->{kind} eq 'sale') ? 1 : 0,
271 is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0,
272 is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0,
273 target_kind => $tk,
274 is_target_model=> ($tk eq 'model') ? 1 : 0,
275 is_target_plan => ($tk eq 'plan') ? 1 : 0,
276 is_target_any => ($tk eq 'any') ? 1 : 0,
277 discount_type => $r->{discount_type},
278 is_dtype_pct => ($r->{discount_type} eq 'percent') ? 1 : 0,
279 is_dtype_amt => ($r->{discount_type} eq 'fixed_amount') ? 1 : 0,
280 is_dtype_bun => ($r->{discount_type} eq 'bundle_price') ? 1 : 0,
281 is_dtype_free => ($r->{discount_type} eq 'free') ? 1 : 0,
282 discount_value_pct => sprintf('%.0f', $pct),
283 discount_value_amt => sprintf('%.2f', $amt),
284 minimum_order => sprintf('%.2f', $min),
285 code => _h($r->{code} || ''),
286 public_slug => _h($r->{public_slug} || ''),
287 starts_at_local => _to_local_input($r->{starts_at}),
288 ends_at_local => _to_local_input($r->{ends_at}),
289 max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : '',
290 max_per_user => defined $r->{max_per_user} ? $r->{max_per_user} : '',
291 new_customers_only => $r->{new_customers_only} ? 1 : 0,
292 status => $r->{status},
293 );
294}
295
296sub _to_local_input {
297 my $ts = shift;
298 return '' unless defined $ts && $ts ne '';
299 if ($ts =~ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/) {
300 return "$1T$2";
301 }
302 return '';
303}
304
305sub _cap_label {
306 my ($r) = @_;
307 my $cur = $r->{current_redemptions} || 0;
308 if (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) {
309 return "$cur of $r->{max_redemptions} used";
310 }
311 return "$cur used";
312}
313
314sub _h {
315 my $s = shift;
316 $s //= '';
317 $s =~ s/&/&/g;
318 $s =~ s/</&lt;/g;
319 $s =~ s/>/&gt;/g;
320 $s =~ s/"/&quot;/g;
321 return $s;
322}
323
324# MySQL DATETIME/DATE string -> UNIX epoch for portfolio-wide <span
325# class="ts"> tz-localization. Empty/unparseable returns 0 so JS falls
326# back to the human string in the span body.
327sub _ap_ts_epoch {
328 my $s = shift;
329 return 0 unless defined $s && length $s;
330 return 0 if $s eq '-' || $s eq 'NEVER';
331 if ($s =~ /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/) {
332 require Time::Local;
333 my $ep = eval { Time::Local::timelocal(($6||0), ($5||0), ($4||0), $3, $2 - 1, $1 - 1900) };
334 return $ep ? $ep + 0 : 0;
335 }
336 return 0;
337}
338
339#======================================================================
340# admin_promotion_action entry: POST handler
341# act=create / update / activate / pause / archive / delete
342#======================================================================
343sub _handle_action {
344 my $act = defined $form->{act} ? $form->{act} : '';
345
346 my $dbh = $db->db_connect();
347 unless ($promo->schema_ready($db, $dbh, $DB)) {
348 $db->db_disconnect($dbh);
349 _act_redirect('/admin_promotions.cgi?err=schema');
350 return;
351 }
352
353 if ($act eq 'create') {
354 my $new_id = $promo->create($db, $dbh, $DB,
355 scope => 'platform',
356 storefront_id => undef,
357 kind => $form->{kind},
358 target_kind => 'plan',
359 name => $form->{name},
360 description => $form->{description},
361 discount_type => $form->{discount_type},
362 discount_value_bp => _act_percent_to_bp($form->{discount_value_pct}),
363 discount_value_cents => _act_dollars_to_cents($form->{discount_value_amt}),
364 minimum_order_cents => _act_dollars_to_cents($form->{minimum_order}),
365 code => $form->{code},
366 public_slug => $form->{public_slug},
367 starts_at => $form->{starts_at_local},
368 ends_at => $form->{ends_at_local},
369 max_redemptions => $form->{max_redemptions},
370 max_per_user => $form->{max_per_user},
371 new_customers_only => $form->{new_customers_only},
372 status => $form->{publish_now} ? 'active' : 'draft',
373 created_by_user_id => $admin_id,
374 );
375 unless ($new_id) {
376 $db->db_disconnect($dbh);
377 _act_redirect('/admin_promotions.cgi?err=bad_input');
378 return;
379 }
380 if (($form->{plan_scope} || 'all') eq 'specific') {
381 my @ids = _act_multi_values($q, 'plan_ids');
382 $promo->set_eligible_plans($db, $dbh, $DB, $new_id, \@ids);
383 } else {
384 $promo->set_eligible_plans($db, $dbh, $DB, $new_id, []);
385 }
386 $db->db_disconnect($dbh);
387 _act_redirect('/admin_promotions.cgi?ok=created');
388 return;
389 }
390
391 if ($act eq 'update') {
392 my $id = $form->{id}; $id =~ s/[^0-9]//g;
393 my $row = $id ? $promo->get_for_admin($db, $dbh, $DB, $id) : {};
394 unless ($row && $row->{id}) {
395 $db->db_disconnect($dbh);
396 _act_redirect('/admin_promotions.cgi?err=denied');
397 return;
398 }
399 my $ok = $promo->update($db, $dbh, $DB, $id,
400 scope => 'platform',
401 storefront_id => undef,
402 kind => $form->{kind},
403 target_kind => 'plan',
404 name => $form->{name},
405 description => $form->{description},
406 discount_type => $form->{discount_type},
407 discount_value_bp => _act_percent_to_bp($form->{discount_value_pct}),
408 discount_value_cents => _act_dollars_to_cents($form->{discount_value_amt}),
409 minimum_order_cents => _act_dollars_to_cents($form->{minimum_order}),
410 code => $form->{code},
411 public_slug => $form->{public_slug},
412 starts_at => $form->{starts_at_local},
413 ends_at => $form->{ends_at_local},
414 max_redemptions => $form->{max_redemptions},
415 max_per_user => $form->{max_per_user},
416 new_customers_only => $form->{new_customers_only},
417 );
418 unless ($ok) {
419 $db->db_disconnect($dbh);
420 _act_redirect('/admin_promotions.cgi?err=bad_input');
421 return;
422 }
423 if (($form->{plan_scope} || 'all') eq 'specific') {
424 my @ids = _act_multi_values($q, 'plan_ids');
425 $promo->set_eligible_plans($db, $dbh, $DB, $id, \@ids);
426 } else {
427 $promo->set_eligible_plans($db, $dbh, $DB, $id, []);
428 }
429 $db->db_disconnect($dbh);
430 _act_redirect('/admin_promotions.cgi?ok=updated');
431 return;
432 }
433
434 if ($act eq 'activate' || $act eq 'pause' || $act eq 'archive') {
435 my $id = $form->{id}; $id =~ s/[^0-9]//g;
436 my $row = $id ? $promo->get_for_admin($db, $dbh, $DB, $id) : {};
437 unless ($row && $row->{id}) {
438 $db->db_disconnect($dbh);
439 _act_redirect('/admin_promotions.cgi?err=denied');
440 return;
441 }
442 my $new_status =
443 $act eq 'activate' ? 'active' :
444 $act eq 'pause' ? 'paused' :
445 'archived';
446 $promo->set_status($db, $dbh, $DB, $id, $new_status);
447 $db->db_disconnect($dbh);
448 _act_redirect('/admin_promotions.cgi?ok=' . ($act eq 'activate' ? 'activated' : $act eq 'pause' ? 'paused' : 'updated'));
449 return;
450 }
451
452 if ($act eq 'delete') {
453 my $id = $form->{id}; $id =~ s/[^0-9]//g;
454 my $row = $id ? $promo->get_for_admin($db, $dbh, $DB, $id) : {};
455 unless ($row && $row->{id}) {
456 $db->db_disconnect($dbh);
457 _act_redirect('/admin_promotions.cgi?err=denied');
458 return;
459 }
460 $promo->delete_promo($db, $dbh, $DB, $id);
461 $db->db_disconnect($dbh);
462 _act_redirect('/admin_promotions.cgi?ok=deleted');
463 return;
464 }
465
466 $db->db_disconnect($dbh);
467 _act_redirect('/admin_promotions.cgi?err=unknown_act');
468 return;
469}
470
471sub _act_redirect { my $url = shift; print "Status: 302 Found\nLocation: $url\n\n"; }
472
473sub _act_multi_values {
474 my ($q, $name) = @_;
475 my @raw = $q->multi_param($name);
476 return () unless @raw;
477 my @out;
478 foreach my $v (@raw) {
479 next unless defined $v;
480 foreach my $piece (split /\0/, $v) {
481 $piece =~ s/[^0-9]//g;
482 push @out, $piece if length $piece;
483 }
484 }
485 return @out;
486}
487sub _act_percent_to_bp {
488 my $v = shift; return 0 unless defined $v && $v ne '';
489 $v =~ s/[^0-9.]//g; return 0 unless length $v;
490 my $bp = int($v * 100); $bp = 0 if $bp < 0; $bp = 10000 if $bp > 10000; return $bp;
491}
492sub _act_dollars_to_cents {
493 my $v = shift; return 0 unless defined $v && $v ne '';
494 $v =~ s/[^0-9.]//g; return 0 unless length $v;
495 return int($v * 100 + 0.5);
496}