added on local at 2026-07-01 22:09:19
| 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 | #====================================================================== | |
| 17 | use strict; | |
| 18 | use warnings; | |
| 19 | ||
| 20 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 21 | use CGI; | |
| 22 | use MODS::Template; | |
| 23 | use MODS::DBConnect; | |
| 24 | use MODS::Login; | |
| 25 | use MODS::ShopCart::Config; | |
| 26 | use MODS::ShopCart::Wrapper; | |
| 27 | use MODS::ShopCart::Admin; | |
| 28 | use MODS::ShopCart::Billing; | |
| 29 | ||
| 30 | my $q = CGI->new; | |
| 31 | my $form = $q->Vars; | |
| 32 | my $auth = MODS::Login->new; | |
| 33 | my $wrap = MODS::ShopCart::Wrapper->new; | |
| 34 | my $tfile = MODS::Template->new; | |
| 35 | my $db = MODS::DBConnect->new; | |
| 36 | my $cfg = MODS::ShopCart::Config->new; | |
| 37 | my $admin = MODS::ShopCart::Admin->new; | |
| 38 | my $bill = MODS::ShopCart::Billing->new; | |
| 39 | my $DB = $cfg->settings('database_name'); | |
| 40 | ||
| 41 | $|=1; | |
| 42 | my $userinfo = $auth->login_verify(); | |
| 43 | ||
| 44 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_packages'; | |
| 45 | ||
| 46 | if ($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 | ||
| 59 | if (!$userinfo) { | |
| 60 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 61 | exit; | |
| 62 | } | |
| 63 | $admin->require_admin($userinfo); | |
| 64 | require 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. | |
| 69 | MODS::ShopCart::Permissions->new->require_super_admin($userinfo); | |
| 70 | ||
| 71 | my $view = ($form->{new} && $form->{new} eq '1') ? 'new' | |
| 72 | : ($form->{edit_id} && $form->{edit_id} =~ /^\d+$/) ? 'edit' | |
| 73 | : 'list'; | |
| 74 | my $edit_id = $form->{edit_id} || ''; | |
| 75 | $edit_id =~ s/[^0-9]//g; | |
| 76 | ||
| 77 | my $flash_msg = ''; | |
| 78 | my $flash_kind = 'ok'; | |
| 79 | if ($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 | ||
| 96 | my $dbh = $db->db_connect(); | |
| 97 | my $schema_ready = $bill->schema_ready($db, $dbh, $DB); | |
| 98 | my $columns_ready = $schema_ready ? $bill->plan_columns_ready($db, $dbh, $DB) : 0; | |
| 99 | my $entitle_ready = $schema_ready ? $bill->plan_features_ready($db, $dbh, $DB) : 0; | |
| 100 | my $migration_pending = ($schema_ready && (!$columns_ready || !$entitle_ready)) ? 1 : 0; | |
| 101 | ||
| 102 | my @catalog = $bill->feature_catalog; | |
| 103 | ||
| 104 | # ---- LIST view: every plan (active + retired) grouped by tier ---- | |
| 105 | my @plans_for_list; | |
| 106 | if ($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 --------------------------------------------------- | |
| 149 | my %edit_row; | |
| 150 | my @edit_features; | |
| 151 | if ($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 | } | |
| 202 | if ($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. | |
| 243 | my @feature_catalog_view; | |
| 244 | foreach 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 | ||
| 265 | my $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 | ||
| 285 | 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"; | |
| 286 | ||
| 287 | my $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 | #---------------------------------------------------------------------- | |
| 297 | sub _h_attr { | |
| 298 | my $s = shift; $s = '' unless defined $s; | |
| 299 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 300 | return $s; | |
| 301 | } | |
| 302 | sub _h_textarea { | |
| 303 | my $s = shift; $s = '' unless defined $s; | |
| 304 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/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 | #====================================================================== | |
| 317 | sub _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. | |
| 412 | sub _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 | } |