added on WebSTLs (webstls.com) at 2026-07-01 22:26:40
| 1 | package MODS::WebSTLs::Receipts; | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs - buyer-order receipt emails. | |
| 4 | # | |
| 5 | # After a paid order lands (Stripe webhook OR free-order short-circuit | |
| 6 | # in checkout_intent.cgi), this module composes + sends the receipt | |
| 7 | # email to the buyer. Each downloadable file gets a signed token URL | |
| 8 | # minted via MODS::WebSTLs::SignedDownload so a GUEST buyer (no | |
| 9 | # storefront buyer_account) can come back and download from the email | |
| 10 | # link without authenticating. | |
| 11 | # | |
| 12 | # Public surface: | |
| 13 | # MODS::WebSTLs::Receipts->send_for_order($db, $dbh, $DB, $order_id); | |
| 14 | # | |
| 15 | # Returns 1 on send attempted (Mailer might still fail), 0 on | |
| 16 | # fundamental misconfig (no order row, no email pipeline, etc.). | |
| 17 | # Failures are logged to STDERR but never thrown -- the caller flow | |
| 18 | # (Stripe webhook) must keep going either way. | |
| 19 | # | |
| 20 | # Idempotency: this module does NOT track whether a receipt has been | |
| 21 | # sent. If the webhook fires twice (Stripe retries), the buyer will | |
| 22 | # get two emails. To prevent that, the caller's outer idempotency | |
| 23 | # (orders.status='paid' guard) is what we rely on -- the webhook | |
| 24 | # branch already skips if the order is already paid. | |
| 25 | #====================================================================== | |
| 26 | ||
| 27 | use strict; | |
| 28 | use warnings; | |
| 29 | ||
| 30 | use MODS::WebSTLs::Config; | |
| 31 | use MODS::WebSTLs::Mailer; | |
| 32 | use MODS::WebSTLs::SignedDownload; | |
| 33 | ||
| 34 | sub send_for_order { | |
| 35 | my ($class, $db, $dbh, $DB, $order_id) = @_; | |
| 36 | $order_id =~ s/[^0-9]//g if defined $order_id; | |
| 37 | return 0 unless $order_id; | |
| 38 | ||
| 39 | my $cfg = MODS::WebSTLs::Config->new; | |
| 40 | my $mailer = MODS::WebSTLs::Mailer->new; | |
| 41 | return 0 unless $mailer->is_configured; | |
| 42 | ||
| 43 | # Load the order + storefront + paid_at as epoch (SignedDownload | |
| 44 | # mints token from epoch seconds). | |
| 45 | my $order = $db->db_readwrite($dbh, qq~ | |
| 46 | SELECT o.id, o.buyer_email, o.total_amount_cents, o.currency, | |
| 47 | o.status, o.paid_at, UNIX_TIMESTAMP(o.paid_at) AS paid_at_unix, | |
| 48 | s.id AS storefront_id, s.name AS store_name, s.subdomain | |
| 49 | FROM `${DB}`.orders o | |
| 50 | JOIN `${DB}`.storefronts s ON s.id = o.storefront_id | |
| 51 | WHERE o.id='$order_id' LIMIT 1 | |
| 52 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 53 | unless ($order && $order->{id}) { | |
| 54 | print STDERR "Receipts: order $order_id not found\n"; | |
| 55 | return 0; | |
| 56 | } | |
| 57 | unless ($order->{buyer_email}) { | |
| 58 | print STDERR "Receipts: order $order_id has no buyer_email\n"; | |
| 59 | return 0; | |
| 60 | } | |
| 61 | unless (($order->{status} || '') eq 'paid') { | |
| 62 | print STDERR "Receipts: order $order_id not paid, skipping receipt\n"; | |
| 63 | return 0; | |
| 64 | } | |
| 65 | my $paid_unix = $order->{paid_at_unix} || time(); | |
| 66 | ||
| 67 | # Line items + the clean downloadable file per item. Same join | |
| 68 | # pattern checkout_complete.cgi uses to build in-app download | |
| 69 | # links; we mint signed URLs instead of session-only paths. | |
| 70 | my @items = $db->db_readwrite_multiple($dbh, qq~ | |
| 71 | SELECT oi.id AS order_item_id, oi.price_paid_cents, oi.quantity, | |
| 72 | m.id AS model_id, m.title | |
| 73 | FROM `${DB}`.order_items oi | |
| 74 | JOIN `${DB}`.models m ON m.id = oi.model_id | |
| 75 | WHERE oi.order_id='$order_id' | |
| 76 | ORDER BY oi.id | |
| 77 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 78 | ||
| 79 | # Resolve the canonical "primary clean file" per item -- same | |
| 80 | # selection rule download.cgi uses for ?item=N: scan_status='clean', | |
| 81 | # is_supported_variant DESC, id ASC. | |
| 82 | my @line_rows; | |
| 83 | my $base = _base_url(); | |
| 84 | foreach my $it (@items) { | |
| 85 | my $title = $it->{title} || ('Model #' . $it->{model_id}); | |
| 86 | my $file = $db->db_readwrite($dbh, qq~ | |
| 87 | SELECT id, filename | |
| 88 | FROM `${DB}`.model_files | |
| 89 | WHERE model_id='$it->{model_id}' | |
| 90 | AND scan_status='clean' | |
| 91 | ORDER BY is_supported_variant DESC, id ASC | |
| 92 | LIMIT 1 | |
| 93 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 94 | ||
| 95 | my $dl_url = ''; | |
| 96 | if ($file && $file->{id}) { | |
| 97 | my $tok = MODS::WebSTLs::SignedDownload->mint( | |
| 98 | $order_id, $file->{id}, $paid_unix | |
| 99 | ); | |
| 100 | $dl_url = "$base/download.cgi?order=$order_id&file=$file->{id}&t=$tok" if $tok; | |
| 101 | } | |
| 102 | push @line_rows, { | |
| 103 | title => $title, | |
| 104 | qty => $it->{quantity} || 1, | |
| 105 | line_total => '$' . sprintf('%.2f', | |
| 106 | ($it->{price_paid_cents} || 0) | |
| 107 | * ($it->{quantity} || 1) / 100), | |
| 108 | download_url => $dl_url, | |
| 109 | }; | |
| 110 | } | |
| 111 | ||
| 112 | my $brand = $cfg->settings('brand_name') || 'WebSTLs'; | |
| 113 | my $store_name = $order->{store_name} || $order->{subdomain} || $brand; | |
| 114 | my $total_disp = '$' . sprintf('%.2f', ($order->{total_amount_cents} || 0) / 100); | |
| 115 | my $orders_url = "$base/my_orders.cgi?id=$order->{storefront_id}"; | |
| 116 | ||
| 117 | my $subject = "Your $store_name order receipt (#$order_id)"; | |
| 118 | ||
| 119 | # Plain-text body -- bullet list with the download URLs spelled | |
| 120 | # out so an email client without HTML still works. | |
| 121 | my $text = "Thanks for your purchase!\n\n" | |
| 122 | . "Order #$order_id from $store_name\n" | |
| 123 | . "Total: $total_disp\n\n" | |
| 124 | . "Items:\n"; | |
| 125 | foreach my $r (@line_rows) { | |
| 126 | $text .= " - $r->{title} (x$r->{qty}) -- $r->{line_total}\n"; | |
| 127 | $text .= " Download: $r->{download_url}\n" if $r->{download_url}; | |
| 128 | } | |
| 129 | $text .= "\nDownload links expire in 30 days.\n"; | |
| 130 | $text .= "If you have an account on $store_name you can also see this order at $orders_url\n\n"; | |
| 131 | $text .= "-- $brand\n"; | |
| 132 | ||
| 133 | # HTML body. Inline styles only -- email clients strip <style>. | |
| 134 | my $items_html = ''; | |
| 135 | foreach my $r (@line_rows) { | |
| 136 | my $dl = $r->{download_url} | |
| 137 | ? '<a href="' . _h($r->{download_url}) . '" style="display:inline-block;background:#4f46e5;color:#fff;padding:6px 12px;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600">Download</a>' | |
| 138 | : '<span style="color:#94a3b8;font-size:12px">Not available yet (file still scanning)</span>'; | |
| 139 | $items_html .= '<tr>' | |
| 140 | . '<td style="padding:10px 8px;border-bottom:1px solid #e2e8f0">' . _h($r->{title}) . ' × ' . $r->{qty} . '</td>' | |
| 141 | . '<td style="padding:10px 8px;border-bottom:1px solid #e2e8f0;text-align:right;font-family:monospace">' . $r->{line_total} . '</td>' | |
| 142 | . '<td style="padding:10px 8px;border-bottom:1px solid #e2e8f0;text-align:right">' . $dl . '</td>' | |
| 143 | . '</tr>'; | |
| 144 | } | |
| 145 | my $html = '<div style="font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;color:#0f172a">' | |
| 146 | . '<h2 style="color:#0f172a;margin:0 0 6px">Thanks for your purchase!</h2>' | |
| 147 | . '<p style="color:#475569;margin:0 0 18px">Order <strong>#' . _h($order_id) . '</strong> from <strong>' . _h($store_name) . '</strong></p>' | |
| 148 | . '<table style="width:100%;border-collapse:collapse;margin:18px 0">' . $items_html . '</table>' | |
| 149 | . '<p style="text-align:right;font-size:16px;font-weight:700;margin:0 0 24px">Total: ' . _h($total_disp) . '</p>' | |
| 150 | . '<p style="font-size:12px;color:#94a3b8">Download links expire in 30 days. They are single-purpose and do not require sign-in.</p>' | |
| 151 | . '<p style="font-size:12px;color:#94a3b8">Have a buyer account on ' . _h($store_name) . '? Manage past orders at <a href="' . _h($orders_url) . '" style="color:#4f46e5">' . _h($orders_url) . '</a>.</p>' | |
| 152 | . '<p style="font-size:12px;color:#94a3b8;margin-top:24px">-- ' . _h($brand) . '</p>' | |
| 153 | . '</div>'; | |
| 154 | ||
| 155 | my $r = $mailer->send( | |
| 156 | to => $order->{buyer_email}, | |
| 157 | subject => $subject, | |
| 158 | body => $text, | |
| 159 | html => $html, | |
| 160 | ); | |
| 161 | unless ($r && $r->{ok}) { | |
| 162 | print STDERR "Receipts: failed to send receipt for order $order_id: " | |
| 163 | . ($r->{error} || '?') . "\n"; | |
| 164 | } | |
| 165 | return 1; | |
| 166 | } | |
| 167 | ||
| 168 | # Build the request scheme://host base from CGI env -- HTTPS detection | |
| 169 | # honors both HTTPS=on and X-Forwarded-Proto=https for Plesk reverse | |
| 170 | # proxy setups. Falls back to https://webstls.com (the live host). | |
| 171 | sub _base_url { | |
| 172 | my $proto = 'http'; | |
| 173 | $proto = 'https' if ($ENV{HTTPS} || '') =~ /^on$/i; | |
| 174 | $proto = 'https' if ($ENV{HTTP_X_FORWARDED_PROTO} || '') =~ /^https$/i; | |
| 175 | my $host = $ENV{HTTP_HOST} || 'webstls.com'; | |
| 176 | return "$proto://$host"; | |
| 177 | } | |
| 178 | ||
| 179 | sub _h { | |
| 180 | my $s = shift; $s = '' unless defined $s; | |
| 181 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 182 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 183 | return $s; | |
| 184 | } | |
| 185 | ||
| 186 | 1; |