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

O Operator
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
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 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#======================================================================
12use strict;
13use warnings;
14
15use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
16use CGI;
17use MODS::Template;
18use MODS::DBConnect;
19use MODS::Login;
20use MODS::ShopCart::Config;
21use MODS::ShopCart::Wrapper;
22use MODS::ShopCart::Admin;
23use MODS::ShopCart::Promotions;
24use MODS::ShopCart::Billing;
25
26my $q = CGI->new;
27my $form = $q->Vars;
28my $auth = MODS::Login->new;
29my $wrap = MODS::ShopCart::Wrapper->new;
30my $tfile = MODS::Template->new;
31my $db = MODS::DBConnect->new;
32my $cfg = MODS::ShopCart::Config->new;
33my $admin = MODS::ShopCart::Admin->new;
34my $promo = MODS::ShopCart::Promotions->new;
35my $DB = $cfg->settings('database_name');
36
37$|=1;
38my $userinfo = $auth->login_verify();
39if (!$userinfo) {
40 print "Status: 302 Found\nLocation: /login.cgi\n\n";
41 exit;
42}
43
44my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_promotions';
45
46if ($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);
55require MODS::ShopCart::Permissions;
56MODS::ShopCart::Permissions->new->require_feature($userinfo, 'admin_view_promotions');
57
58my $admin_id = $userinfo->{user_id};
59$admin_id =~ s/[^0-9]//g;
60
61my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
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 $dbh = $db->db_connect();
70my $schema_ready = $promo->schema_ready($db, $dbh, $DB);
71
72my @rows_raw = $schema_ready ? $promo->list_for_admin($db, $dbh, $DB, 200) : ();
73
74my @rows;
75my $cnt_active = 0; my $cnt_scheduled = 0; my $cnt_paused = 0; my $cnt_expired = 0;
76my $sum_redemptions = 0; my $sum_discount_cents = 0;
77foreach 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
89my %edit_row;
90if ($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.
105my @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
124my %flash;
125if ($form->{ok} eq 'created') { %flash = (kind=>'ok', msg=>'Platform promotion created.'); }
126elsif ($form->{ok} eq 'updated') { %flash = (kind=>'ok', msg=>'Platform promotion updated.'); }
127elsif ($form->{ok} eq 'deleted') { %flash = (kind=>'ok', msg=>'Platform promotion deleted.'); }
128elsif ($form->{ok} eq 'activated') { %flash = (kind=>'ok', msg=>'Promotion is now active across the platform.'); }
129elsif ($form->{ok} eq 'paused') { %flash = (kind=>'ok', msg=>'Promotion paused.'); }
130elsif ($form->{err} eq 'bad_input') { %flash = (kind=>'err', msg=>'Required fields missing.'); }
131elsif ($form->{err} eq 'denied') { %flash = (kind=>'err', msg=>'Action not permitted.'); }
132
133$db->db_disconnect($dbh);
134
135my $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.
169if ($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
205print "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
207my $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
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 => _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
256sub _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
294sub _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
303sub _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
313sub _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
322sub _h {
323 my $s = shift;
324 $s //= '';
325 $s =~ s/&/&amp;/g;
326 $s =~ s/</&lt;/g;
327 $s =~ s/>/&gt;/g;
328 $s =~ s/"/&quot;/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#======================================================================
341sub _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
481sub _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.
484sub _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}
498sub _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}
503sub _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}