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