Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/MODS/ABForge/Receipts.pm
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/MODS/ABForge/Receipts.pm

added on local at 2026-07-01 16:01:28

Added
+186
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to c20079cf44ac
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::ABForge::Receipts;
2#======================================================================
3# ABForge - 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::ABForge::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::ABForge::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
27use strict;
28use warnings;
29
30use MODS::ABForge::Config;
31use MODS::ABForge::Mailer;
32use MODS::ABForge::SignedDownload;
33
34sub 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::ABForge::Config->new;
40 my $mailer = MODS::ABForge::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::ABForge::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') || 'ABForge';
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}) . ' &times; ' . $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://abforge.com (the live host).
171sub _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} || 'abforge.com';
176 return "$proto://$host";
177}
178
179sub _h {
180 my $s = shift; $s = '' unless defined $s;
181 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
182 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
183 return $s;
184}
185
1861;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help