added on local at 2026-07-01 21:47:32
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer - Post-purchase landing page (buyer-facing). | |
| 4 | # | |
| 5 | # GET /checkout_complete.cgi?order=N[&payment_intent=...&payment_intent_client_secret=...] | |
| 6 | # | |
| 7 | # Stripe redirects the buyer here after stripe.confirmPayment(). The | |
| 8 | # definitive payment state comes from the webhook (orders.status), but | |
| 9 | # Stripe also appends a `redirect_status` query param we use as a hint | |
| 10 | # while the webhook is in flight. | |
| 11 | #====================================================================== | |
| 12 | use strict; | |
| 13 | use warnings; | |
| 14 | ||
| 15 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 16 | use CGI; | |
| 17 | use MODS::Template; | |
| 18 | use MODS::DBConnect; | |
| 19 | use MODS::RePricer::Config; | |
| 20 | use MODS::RePricer::Themes; | |
| 21 | use MODS::RePricer::Cart; | |
| 22 | use MODS::RePricer::Tax; | |
| 23 | ||
| 24 | $| = 1; | |
| 25 | ||
| 26 | my $q = CGI->new; | |
| 27 | my $form = $q->Vars; | |
| 28 | my $tfile = MODS::Template->new; | |
| 29 | my $db = MODS::DBConnect->new; | |
| 30 | my $config = MODS::RePricer::Config->new; | |
| 31 | my $themes = MODS::RePricer::Themes->new; | |
| 32 | my $cart = MODS::RePricer::Cart->new; | |
| 33 | my $DB = $config->settings('database_name'); | |
| 34 | ||
| 35 | my $oid = $form->{order} || 0; | |
| 36 | $oid =~ s/[^0-9]//g; | |
| 37 | ||
| 38 | my $redirect_status = $form->{redirect_status} || ''; | |
| 39 | ||
| 40 | unless ($oid) { | |
| 41 | print "Status: 302 Found\nLocation: /\n\n"; | |
| 42 | exit; | |
| 43 | } | |
| 44 | ||
| 45 | my $dbh = $db->db_connect(); | |
| 46 | ||
| 47 | my $order = $db->db_readwrite($dbh, qq~ | |
| 48 | SELECT o.id, o.storefront_id, o.status, o.buyer_email, | |
| 49 | o.total_amount_cents, o.subtotal_cents, o.tax_amount_cents, o.currency, | |
| 50 | s.name AS store_name, s.subdomain, s.theme_id | |
| 51 | FROM `${DB}`.orders o | |
| 52 | JOIN `${DB}`.storefronts s ON s.id = o.storefront_id | |
| 53 | WHERE o.id='$oid' LIMIT 1 | |
| 54 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 55 | ||
| 56 | unless ($order && $order->{id}) { | |
| 57 | $db->db_disconnect($dbh); | |
| 58 | print "Status: 302 Found\nLocation: /\n\n"; | |
| 59 | exit; | |
| 60 | } | |
| 61 | ||
| 62 | # Pull line items so the receipt is itemized. | |
| 63 | my @items = $db->db_readwrite_multiple($dbh, qq~ | |
| 64 | SELECT oi.id, oi.price_paid_cents, oi.quantity, | |
| 65 | m.id AS model_id, m.title | |
| 66 | FROM `${DB}`.order_items oi | |
| 67 | JOIN `${DB}`.models m ON m.id = oi.model_id | |
| 68 | WHERE oi.order_id='$oid' | |
| 69 | ORDER BY oi.id | |
| 70 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 71 | ||
| 72 | # Clear the cart if the order is paid (webhook already fired) so the | |
| 73 | # buyer doesn't see stale items when they return to the storefront. | |
| 74 | if (($order->{status} || '') eq 'paid') { | |
| 75 | my $token = $cart->read_token($q); | |
| 76 | $cart->clear($db, $dbh, $DB, $token, $order->{storefront_id}) if $token; | |
| 77 | } | |
| 78 | ||
| 79 | my $theme = $themes->by_id($order->{theme_id}); | |
| 80 | my $css_vars = $themes->css_vars($theme); | |
| 81 | ||
| 82 | # Per-jurisdiction tax breakdown for the receipt. | |
| 83 | my $tax_summary = MODS::RePricer::Tax->new->summary_for_order($db, $dbh, $DB, $oid); | |
| 84 | ||
| 85 | $db->db_disconnect($dbh); | |
| 86 | ||
| 87 | my $status = $order->{status} || 'pending'; | |
| 88 | ||
| 89 | # UI state. Three possibilities: | |
| 90 | # paid -> webhook already fired; show success + download links | |
| 91 | # pending -> webhook in flight; show "processing" with auto-refresh | |
| 92 | # failed -> something went wrong; surface the error path | |
| 93 | my $is_paid = ($status eq 'paid') ? 1 : 0; | |
| 94 | my $is_failed = ($status eq 'failed') ? 1 : 0; | |
| 95 | my $is_pending = (!$is_paid && !$is_failed) ? 1 : 0; | |
| 96 | ||
| 97 | # A failed redirect_status from Stripe means the buyer's card was | |
| 98 | # declined or 3DS failed -- treat that as definitive failure even if | |
| 99 | # our DB row is still pending. | |
| 100 | if (lc($redirect_status) eq 'failed') { | |
| 101 | $is_paid = 0; | |
| 102 | $is_pending = 0; | |
| 103 | $is_failed = 1; | |
| 104 | } | |
| 105 | ||
| 106 | my @item_rows; | |
| 107 | foreach my $it (@items) { | |
| 108 | my $title = $it->{title} || ('Model #' . $it->{model_id}); | |
| 109 | $title =~ s/&/&/g; $title =~ s/</</g; $title =~ s/>/>/g; $title =~ s/"/"/g; | |
| 110 | push @item_rows, { | |
| 111 | title => $title, | |
| 112 | qty => $it->{quantity} || 1, | |
| 113 | line_total => '$' . sprintf('%.2f', ($it->{price_paid_cents} || 0) * ($it->{quantity} || 1) / 100), | |
| 114 | download_url => $is_paid ? "/download.cgi?order=$oid&item=$it->{id}" : '', | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | my @tax_rows; | |
| 119 | foreach my $l (@$tax_summary) { | |
| 120 | push @tax_rows, { | |
| 121 | label => $l->{jurisdiction_label}, | |
| 122 | rate => sprintf('%.2f', $l->{rate_pct}), | |
| 123 | cents => '$' . sprintf('%.2f', ($l->{tax_amount_cents} || 0) / 100), | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | require MODS::RePricer::StoreChrome; | |
| 128 | my $chrome_dbh = $db->db_connect(); | |
| 129 | my $chrome_html = MODS::RePricer::StoreChrome->header_html({ | |
| 130 | store_id => $order->{storefront_id}, | |
| 131 | store_name => $order->{store_name} || $order->{subdomain}, | |
| 132 | is_buyer_in => ($order->{buyer_email} ? 1 : 0), | |
| 133 | cart_count => 0, | |
| 134 | db => $db, | |
| 135 | dbh => $chrome_dbh, | |
| 136 | DB => $DB, | |
| 137 | }); | |
| 138 | $db->db_disconnect($chrome_dbh); | |
| 139 | ||
| 140 | my $tvars = { | |
| 141 | theme_css_vars => $css_vars, | |
| 142 | order_id => $oid + 0, | |
| 143 | store_name => $order->{store_name} || $order->{subdomain}, | |
| 144 | store_id => $order->{storefront_id}, | |
| 145 | store_chrome_html => $chrome_html, | |
| 146 | buyer_email => $order->{buyer_email}, | |
| 147 | subtotal_display => '$' . sprintf('%.2f', ($order->{subtotal_cents} || 0) / 100), | |
| 148 | tax_display => '$' . sprintf('%.2f', ($order->{tax_amount_cents} || 0) / 100), | |
| 149 | has_tax => ($order->{tax_amount_cents} && $order->{tax_amount_cents} > 0) ? 1 : 0, | |
| 150 | tax_lines => \@tax_rows, | |
| 151 | total_display => '$' . sprintf('%.2f', ($order->{total_amount_cents} || 0) / 100), | |
| 152 | is_paid => $is_paid, | |
| 153 | is_pending => $is_pending, | |
| 154 | is_failed => $is_failed, | |
| 155 | items => \@item_rows, | |
| 156 | has_items => scalar(@item_rows) ? 1 : 0, | |
| 157 | back_href => "/store.cgi?id=$order->{storefront_id}", | |
| 158 | orders_href => "/my_orders.cgi?id=$order->{storefront_id}", | |
| 159 | }; | |
| 160 | ||
| 161 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 162 | print join('', $tfile->template('repricer_checkout_complete.html', $tvars, undef)); |