added on local at 2026-07-01 21:47:31
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer - Checkout page (buyer-facing). | |
| 4 | # | |
| 5 | # GET /checkout.cgi?id=<storefront_id> | |
| 6 | # | |
| 7 | # When the storefront is connected to Stripe, this page renders the | |
| 8 | # real Stripe Elements payment form. The actual PaymentIntent is | |
| 9 | # created on demand by /checkout_intent.cgi (POSTed from the form) so | |
| 10 | # we don't generate intents for buyers who never type their email. | |
| 11 | # | |
| 12 | # When Stripe is not configured (platform-wide or seller-side), this | |
| 13 | # page falls back to a "checkout coming soon" panel. | |
| 14 | #====================================================================== | |
| 15 | use strict; | |
| 16 | use warnings; | |
| 17 | ||
| 18 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 19 | use CGI; | |
| 20 | use MODS::Template; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::RePricer::Config; | |
| 23 | use MODS::RePricer::Themes; | |
| 24 | use MODS::RePricer::Cart; | |
| 25 | use MODS::RePricer::Stripe; | |
| 26 | use MODS::RePricer::BuyerAuth; | |
| 27 | ||
| 28 | $| = 1; | |
| 29 | ||
| 30 | my $q = CGI->new; | |
| 31 | my $form = $q->Vars; | |
| 32 | my $tfile = MODS::Template->new; | |
| 33 | my $db = MODS::DBConnect->new; | |
| 34 | my $config = MODS::RePricer::Config->new; | |
| 35 | my $themes = MODS::RePricer::Themes->new; | |
| 36 | my $cart = MODS::RePricer::Cart->new; | |
| 37 | my $stripe = MODS::RePricer::Stripe->new; | |
| 38 | my $buyer_auth = MODS::RePricer::BuyerAuth->new; | |
| 39 | my $DB = $config->settings('database_name'); | |
| 40 | ||
| 41 | my $sid = $form->{id} || $form->{storefront_id} || 0; | |
| 42 | $sid =~ s/[^0-9]//g; | |
| 43 | unless ($sid) { | |
| 44 | print "Status: 302 Found\nLocation: /\n\n"; | |
| 45 | exit; | |
| 46 | } | |
| 47 | ||
| 48 | my $dbh = $db->db_connect(); | |
| 49 | my $store = $db->db_readwrite($dbh, | |
| 50 | qq~SELECT id, name, subdomain, theme_id, stripe_account_id, stripe_onboarded_at | |
| 51 | FROM `${DB}`.storefronts WHERE id='$sid' AND status='live' LIMIT 1~, | |
| 52 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 53 | unless ($store && $store->{id}) { | |
| 54 | $db->db_disconnect($dbh); | |
| 55 | print "Status: 302 Found\nLocation: /\n\n"; | |
| 56 | exit; | |
| 57 | } | |
| 58 | ||
| 59 | my $theme = $themes->by_id($store->{theme_id}); | |
| 60 | my $css_vars = $themes->css_vars($theme); | |
| 61 | ||
| 62 | my $token = $cart->read_token($q); | |
| 63 | my @rows = $cart->items($db, $dbh, $DB, $token, $sid); | |
| 64 | my $total = $cart->total_cents($db, $dbh, $DB, $token, $sid); | |
| 65 | ||
| 66 | # Detect whether any cart line requires shipping (a kind='physical' | |
| 67 | # row). When so, the checkout page renders a shipping address form | |
| 68 | # and a shipping-fee selector. Rates come from the seller's | |
| 69 | # shipping_options_json (zone=cents lines on the model row). | |
| 70 | my $needs_shipping = $cart->requires_shipping($db, $dbh, $DB, $token, $sid); | |
| 71 | ||
| 72 | # Resolve the storefront's shipping zones. We probe models touched by | |
| 73 | # this cart and use the FIRST physical line's shipping_options_json | |
| 74 | # as the menu -- realistic for single-seller storefronts (the common | |
| 75 | # case here). A multi-line cart with diverging rates per model would | |
| 76 | # need a more elaborate "max per line" calc later. | |
| 77 | my @ship_zones; | |
| 78 | my $ship_from_country = ''; | |
| 79 | if ($needs_shipping) { | |
| 80 | foreach my $r (@rows) { | |
| 81 | next unless ($r->{purchase_kind} || '') eq 'physical'; | |
| 82 | my $mid = $r->{model_id}; $mid =~ s/[^0-9]//g; | |
| 83 | next unless $mid; | |
| 84 | my $mr = $db->db_readwrite($dbh, qq~ | |
| 85 | SELECT shipping_options_json, ship_from_country | |
| 86 | FROM `${DB}`.models WHERE id='$mid' LIMIT 1 | |
| 87 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 88 | next unless $mr; | |
| 89 | $ship_from_country ||= $mr->{ship_from_country} || ''; | |
| 90 | my $raw = $mr->{shipping_options_json} || ''; | |
| 91 | foreach my $line (split /\r?\n/, $raw) { | |
| 92 | $line =~ s/^\s+|\s+$//g; | |
| 93 | next unless $line =~ /^([A-Za-z0-9_\-]{1,40})\s*=\s*(\d+)$/; | |
| 94 | push @ship_zones, { slug => $1, cents => $2 + 0 }; | |
| 95 | } | |
| 96 | last; # take the first physical model's rate set | |
| 97 | } | |
| 98 | # No rates? Fall back to a single free domestic zone so the | |
| 99 | # buyer can still complete the order. | |
| 100 | unless (@ship_zones) { | |
| 101 | @ship_zones = ({ slug => 'standard', cents => 0 }); | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | # Decorate shipping zones for the radio list. First zone is the | |
| 106 | # default selection so the form always submits a valid ship_zone. | |
| 107 | my @ship_options; | |
| 108 | my $ship_idx = 0; | |
| 109 | foreach my $z (@ship_zones) { | |
| 110 | my $label = ucfirst($z->{slug}); | |
| 111 | $label =~ s/_/ /g; | |
| 112 | push @ship_options, { | |
| 113 | slug => $z->{slug}, | |
| 114 | cents => $z->{cents}, | |
| 115 | label => $label, | |
| 116 | cost_label=> $z->{cents} > 0 | |
| 117 | ? '$' . sprintf('%.2f', $z->{cents} / 100) | |
| 118 | : 'Free', | |
| 119 | is_first => ($ship_idx == 0) ? 1 : 0, | |
| 120 | checked_attr => ($ship_idx == 0) ? 'checked' : '', | |
| 121 | }; | |
| 122 | $ship_idx++; | |
| 123 | } | |
| 124 | ||
| 125 | # Pull buyer email from session if they're logged in. Used to prefill | |
| 126 | # the email field; buyer is free to overwrite. | |
| 127 | my $buyer = $buyer_auth->verify($q, $db, $dbh, $DB); | |
| 128 | my $prefill_email = ($buyer && $buyer->{email}) ? $buyer->{email} : ''; | |
| 129 | ||
| 130 | $db->db_disconnect($dbh); | |
| 131 | ||
| 132 | # Three checkout states the template renders against. | |
| 133 | my $platform_ready = $stripe->is_configured ? 1 : 0; | |
| 134 | my $seller_ready = ($store->{stripe_account_id} && $store->{stripe_onboarded_at}) ? 1 : 0; | |
| 135 | my $is_payable = ($platform_ready && $seller_ready && scalar(@rows) && $total > 0) ? 1 : 0; | |
| 136 | ||
| 137 | # Decorate cart items for display. | |
| 138 | my @items; | |
| 139 | foreach my $r (@rows) { | |
| 140 | my $title = $r->{title} || ''; | |
| 141 | $title =~ s/&/&/g; $title =~ s/</</g; $title =~ s/>/>/g; $title =~ s/"/"/g; | |
| 142 | push @items, { | |
| 143 | title => $title, | |
| 144 | qty => $r->{quantity} || 1, | |
| 145 | line_total => '$' . sprintf('%.2f', ($r->{price_cents} || 0) * ($r->{quantity} || 1) / 100), | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | # Storefront header chrome (brand + search + sign in + cart) -- same | |
| 150 | # shared helper every buyer-facing page uses so the header stays | |
| 151 | # consistent across cart/checkout/listing_details/my_orders. | |
| 152 | require MODS::RePricer::StoreChrome; | |
| 153 | # Re-open a connection just for the nav-pages lookup -- the main | |
| 154 | # checkout work already closed $dbh above. | |
| 155 | my $chrome_dbh = $db->db_connect(); | |
| 156 | my $chrome_html = MODS::RePricer::StoreChrome->header_html({ | |
| 157 | store_id => $store->{id}, | |
| 158 | store_name => $store->{name} || $store->{subdomain}, | |
| 159 | is_buyer_in => ($prefill_email ? 1 : 0), | |
| 160 | cart_count => scalar(@rows), | |
| 161 | db => $db, | |
| 162 | dbh => $chrome_dbh, | |
| 163 | DB => $DB, | |
| 164 | }); | |
| 165 | $db->db_disconnect($chrome_dbh); | |
| 166 | ||
| 167 | my $tvars = { | |
| 168 | theme_css_vars => $css_vars, | |
| 169 | store_id => $store->{id}, | |
| 170 | store_name => $store->{name} || $store->{subdomain}, | |
| 171 | store_chrome_html => $chrome_html, | |
| 172 | item_count => scalar(@rows), | |
| 173 | has_items => scalar(@rows) ? 1 : 0, | |
| 174 | items => \@items, | |
| 175 | total_display => '$' . sprintf('%.2f', $total / 100), | |
| 176 | total_cents => $total, | |
| 177 | cart_href => "/cart.cgi?id=$sid", | |
| 178 | back_href => "/store.cgi?id=$sid", | |
| 179 | ||
| 180 | is_payable => $is_payable, | |
| 181 | not_payable => $is_payable ? 0 : 1, | |
| 182 | seller_not_ready => ($platform_ready && !$seller_ready) ? 1 : 0, | |
| 183 | platform_not_ready => $platform_ready ? 0 : 1, | |
| 184 | ||
| 185 | stripe_pk => $stripe->publishable_key, | |
| 186 | prefill_email => $prefill_email, | |
| 187 | ||
| 188 | needs_shipping => $needs_shipping, | |
| 189 | ship_options => \@ship_options, | |
| 190 | has_ship_options => $needs_shipping ? 1 : 0, | |
| 191 | ship_from_country => $ship_from_country, | |
| 192 | }; | |
| 193 | ||
| 194 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 195 | print join('', $tfile->template('repricer_checkout.html', $tvars, undef)); |