Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/billing_payment.cgi
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/billing_payment.cgi

added on local at 2026-07-01 21:47:04

Added
+425
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 225bf645a10b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# RePricer - Manage saved card (Stripe SetupIntent flow)
4#
5# Dedicated card-management page. Linked from the "Payment method"
6# panel on /billing.cgi. Three responsibilities:
7#
8# 1. GET (default) -- render current cards + Stripe
9# Elements card-entry form.
10# 2. POST act=save_card -- finalize a confirmed SetupIntent:
11# attach to customer, persist
12# brand/last4/exp locally.
13# 3. POST act=set_default -- promote one saved card to default
14# (locally + on the Stripe customer).
15# 4. POST act=delete_card -- detach + delete one saved card.
16#
17# Card data itself NEVER touches our server. The browser confirms the
18# SetupIntent against Stripe Elements; only the resulting pm_xxxx id
19# is POSTed back here. brand/last4/exp are read from the Stripe API
20# (server-side) and mirrored into payment_methods for UI display.
21#
22# Gated by the `manage_billing` seller-scope ptag. Owners auto-pass
23# via require_feature's owner short-circuit.
24#======================================================================
25use strict;
26use warnings;
27
28use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
29use CGI;
30use MODS::Template;
31use MODS::DBConnect;
32use MODS::Login;
33use MODS::RePricer::Config;
34use MODS::RePricer::Wrapper;
35use MODS::RePricer::Billing;
36use MODS::RePricer::Permissions;
37use MODS::RePricer::Stripe;
38
39my $q = CGI->new;
40my $form = $q->Vars;
41my $auth = MODS::Login->new;
42my $wrap = MODS::RePricer::Wrapper->new;
43my $tfile = MODS::Template->new;
44my $db = MODS::DBConnect->new;
45my $cfg = MODS::RePricer::Config->new;
46my $bill = MODS::RePricer::Billing->new;
47my $stripe = MODS::RePricer::Stripe->new;
48my $perm = MODS::RePricer::Permissions->new;
49my $DB = $cfg->settings('database_name');
50
51$|=1;
52my $userinfo = $auth->login_verify();
53if (!$userinfo) {
54 print "Status: 302 Found\nLocation: /login.cgi\n\n";
55 exit;
56}
57# Ptag gate. Owners short-circuit; team members need `manage_billing`.
58$perm->require_feature($userinfo, 'manage_billing', '/billing.cgi?denied=manage_billing');
59
60my $uid = $userinfo->{user_id};
61$uid =~ s/[^0-9]//g;
62
63my $method = uc($ENV{REQUEST_METHOD} || 'GET');
64my $act = defined $form->{act} ? $form->{act} : '';
65
66my $dbh = $db->db_connect();
67unless ($bill->schema_ready($db, $dbh, $DB)) {
68 $db->db_disconnect($dbh);
69 _redirect('/billing.cgi?err=schema');
70 exit;
71}
72
73# -------- POST actions ----------------------------------------------
74if ($method eq 'POST' && $act eq 'save_card') {
75 # Browser confirmed the SetupIntent and POSTed the resulting PM id
76 # back. We attach (idempotent), retrieve the card metadata, and
77 # persist locally. We deliberately re-attach even on success to
78 # protect against the rare case where confirmation succeeded but
79 # the auto-attach race lost.
80 my $pm_id = defined $form->{stripe_payment_method_id} ? $form->{stripe_payment_method_id} : '';
81 $pm_id =~ s/[^A-Za-z0-9_]//g;
82 unless ($pm_id && $pm_id =~ /^pm_/) {
83 $db->db_disconnect($dbh);
84 _redirect('/billing_payment.cgi?err=bad_pm');
85 exit;
86 }
87
88 my $cus = $bill->ensure_stripe_customer($db, $dbh, $DB, $userinfo, $stripe);
89 unless ($cus) {
90 $db->db_disconnect($dbh);
91 _redirect('/billing_payment.cgi?err=stripe_customer');
92 exit;
93 }
94
95 my $att = $stripe->attach_payment_method($pm_id, $cus);
96 if ($att && $att->{error}) {
97 # Already-attached returns an error; treat as success and
98 # carry on. Anything else is a real failure.
99 unless ($att->{error} =~ /already been attached/i) {
100 $db->db_disconnect($dbh);
101 _redirect('/billing_payment.cgi?err=attach');
102 exit;
103 }
104 }
105
106 my $pm = $stripe->retrieve_payment_method($pm_id);
107 if (!$pm || $pm->{error} || !$pm->{id}) {
108 $db->db_disconnect($dbh);
109 _redirect('/billing_payment.cgi?err=retrieve');
110 exit;
111 }
112
113 my $card = $pm->{card} || {};
114 my $row_id = $bill->save_payment_method($db, $dbh, $DB, $uid,
115 brand => ($card->{brand} || ''),
116 last4 => ($card->{last4} || ''),
117 exp_month => ($card->{exp_month} || 0),
118 exp_year => ($card->{exp_year} || 0),
119 stripe_payment_method_id => $pm_id,
120 stripe_customer_id => $cus,
121 );
122
123 # Promote to default on the Stripe customer so the next invoice
124 # charges this card. If we already had a different default locally
125 # this won't auto-promote -- explicit "Make default" handles that.
126 my @existing = $bill->list_payment_methods($db, $dbh, $DB, $uid);
127 my $is_only = (scalar(@existing) <= 1) ? 1 : 0;
128 if ($is_only) {
129 $stripe->set_customer_default_pm($cus, $pm_id);
130 }
131
132 $db->db_disconnect($dbh);
133 # If this card was added to complete a pending upgrade
134 # (?next_plan=N rode along the SetupIntent confirmation), bounce
135 # back to billing_action.cgi so the upgrade closes the loop.
136 my $resume_plan = $form->{next_plan} || '';
137 $resume_plan =~ s/[^0-9]//g;
138 if ($resume_plan) {
139 _redirect('/billing.cgi?ok=card_saved&resume_plan=' . $resume_plan);
140 } else {
141 _redirect('/billing.cgi?ok=card_saved');
142 }
143 exit;
144}
145
146if ($method eq 'POST' && $act eq 'set_default') {
147 my $pm_id = defined $form->{pm_id} ? $form->{pm_id} : '';
148 $pm_id =~ s/[^0-9]//g;
149 unless ($pm_id) {
150 $db->db_disconnect($dbh);
151 _redirect('/billing_payment.cgi?err=bad_pm');
152 exit;
153 }
154 my $row = $bill->payment_method_by_id($db, $dbh, $DB, $uid, $pm_id);
155 unless ($row) {
156 $db->db_disconnect($dbh);
157 _redirect('/billing_payment.cgi?err=not_found');
158 exit;
159 }
160 $bill->set_default_payment_method($db, $dbh, $DB, $uid, $pm_id);
161 if ($row->{stripe_customer_id} && $row->{stripe_payment_method_id}) {
162 $stripe->set_customer_default_pm(
163 $row->{stripe_customer_id},
164 $row->{stripe_payment_method_id},
165 );
166 }
167 $db->db_disconnect($dbh);
168 _redirect('/billing_payment.cgi?ok=default_set');
169 exit;
170}
171
172if ($method eq 'POST' && $act eq 'delete_card') {
173 my $pm_id = defined $form->{pm_id} ? $form->{pm_id} : '';
174 $pm_id =~ s/[^0-9]//g;
175 unless ($pm_id) {
176 $db->db_disconnect($dbh);
177 _redirect('/billing_payment.cgi?err=bad_pm');
178 exit;
179 }
180 my $spm = $bill->delete_payment_method($db, $dbh, $DB, $uid, $pm_id);
181 if ($spm) {
182 # Stripe-side detach is best-effort; we don't roll back the
183 # local delete if Stripe is unreachable, because the row is
184 # already gone and the seller would otherwise see a phantom
185 # card. Detach is idempotent on the Stripe side.
186 $stripe->detach_payment_method($spm);
187 }
188 $db->db_disconnect($dbh);
189 _redirect('/billing_payment.cgi?ok=card_deleted');
190 exit;
191}
192
193# -------- GET (render page) -----------------------------------------
194# Ensure the customer exists before creating a SetupIntent. If Stripe
195# is not configured yet we still render the page but disable the
196# card-entry form -- this matches the "Payments not configured" state
197# used elsewhere in Phase 2.
198my $stripe_ready = $stripe->is_configured ? 1 : 0;
199my $client_secret = '';
200my $cus_id = '';
201
202if ($stripe_ready) {
203 $cus_id = $bill->ensure_stripe_customer($db, $dbh, $DB, $userinfo, $stripe) || '';
204 if ($cus_id) {
205 my $si = $stripe->create_setup_intent(
206 customer => $cus_id,
207 metadata => { user_id => $uid },
208 );
209 if ($si && !$si->{error} && $si->{client_secret}) {
210 $client_secret = $si->{client_secret};
211 }
212 }
213}
214
215# Card list for the "Saved cards" panel.
216my @pms = $bill->list_payment_methods($db, $dbh, $DB, $uid);
217my @cards;
218foreach my $p (@pms) {
219 push @cards, {
220 id => $p->{id},
221 brand_label => ucfirst($p->{brand} || 'card'),
222 last4 => ($p->{last4} || '----'),
223 exp_display => sprintf('%02d/%02d',
224 ($p->{exp_month} || 0),
225 (($p->{exp_year} || 0) % 100)),
226 is_default => $p->{is_default} ? 1 : 0,
227 not_default => $p->{is_default} ? 0 : 1,
228 created_at => ($p->{created_at} || ''),
229 };
230}
231
232# Flash from redirects.
233my $flash_kind = '';
234my $flash_msg = '';
235# need_card=1&next_plan=N is set by billing_action.cgi when the user
236# tried to upgrade without a card on file. Show a contextual banner +
237# auto-resume the upgrade once a card is saved.
238my $need_card = ($form->{need_card} && $form->{need_card} eq '1') ? 1 : 0;
239my $next_plan = $form->{next_plan} || '';
240$next_plan =~ s/[^0-9]//g;
241if ($need_card && $next_plan) {
242 $flash_kind = 'danger';
243 $flash_msg = 'A payment method is required to complete your upgrade. Add a card below and we will pick the upgrade back up.';
244}
245if (defined $form->{ok}) {
246 my $k = $form->{ok}; $k =~ s/[^a-z_]//g;
247 $flash_kind = 'ok';
248 $flash_msg = 'default_set' eq $k ? 'Default card updated.'
249 : 'card_deleted' eq $k ? 'Card removed.'
250 : 'card_saved' eq $k ? 'Card saved.'
251 : 'Done.';
252}
253if (defined $form->{err}) {
254 my $k = $form->{err}; $k =~ s/[^a-z_]//g;
255 $flash_kind = 'danger';
256 $flash_msg = 'bad_pm' eq $k ? 'Could not identify the card.'
257 : 'stripe_customer' eq $k ? 'Could not set up a Stripe customer profile. Try again in a moment.'
258 : 'attach' eq $k ? 'Stripe rejected the card. Try a different card or contact your bank.'
259 : 'retrieve' eq $k ? 'Saved, but we could not read the card brand back from Stripe. Refresh to see it.'
260 : 'not_found' eq $k ? 'That card is no longer on file.'
261 : 'Something went wrong. Please try again.';
262}
263
264$db->db_disconnect($dbh);
265
266my $tvars = {
267 user_id => $uid,
268 cards => \@cards,
269 has_cards => scalar(@cards) ? 1 : 0,
270 no_cards => scalar(@cards) ? 0 : 1,
271 stripe_ready => $stripe_ready,
272 stripe_missing => $stripe_ready ? 0 : 1,
273 publishable_key => $stripe->publishable_key,
274 client_secret => $client_secret,
275 has_setup => ($stripe_ready && $client_secret) ? 1 : 0,
276 flash_kind => $flash_kind,
277 flash_msg => $flash_msg,
278 has_flash => length $flash_msg ? 1 : 0,
279 flash_is_err => ($flash_kind eq 'danger') ? 1 : 0,
280 flash_is_ok => ($flash_kind eq 'ok') ? 1 : 0,
281 need_card => $need_card,
282 next_plan_id => $next_plan,
283 has_next_plan => ($need_card && $next_plan) ? 1 : 0,
284};
285
286# --- Inline HTML render -------------------------------------------------
287# Bypass the WebSTLs-inherited template engine (which hangs silently
288# mid-parse on repricer_billing_payment.html). Stripe Elements is the
289# critical FE -- we wire it directly here so the customer flow works.
290
291sub _esc { my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; $s }
292
293my $body = '<div style="max-width:760px;margin:0 auto;padding:24px 28px">';
294$body .= ' <div style="margin-bottom:24px">';
295$body .= ' <div style="font-size:11px;letter-spacing:2px;color:#14b8a6;text-transform:uppercase">Billing &middot; Payment method</div>';
296$body .= ' <h1 style="font-size:26px;color:#fff;margin:6px 0 4px;font-weight:700">Manage card on file</h1>';
297$body .= ' <p style="color:#7e92b6;font-size:14px;margin:0">Card data never touches our server &mdash; Stripe Elements collects it directly. We only store the brand and last 4 digits for display.</p>';
298$body .= ' </div>';
299
300# Flash
301if ($flash_msg) {
302 my $color = ($flash_kind eq 'danger') ? '#fca5a5' : '#10b981';
303 my $msg = _esc($flash_msg);
304 $body .= qq~<div style="background:${color}1a;border-left:3px solid $color;color:$color;padding:12px 16px;border-radius:8px;margin-bottom:18px;font-size:14px">$msg</div>~;
305}
306
307# Stripe-not-configured banner
308unless ($stripe_ready) {
309 $body .= '<div style="background:#451a03;border:1px solid #ea580c;color:#fdba74;padding:14px 18px;border-radius:8px;margin-bottom:18px;font-size:14px">';
310 $body .= ' <b>Payments not configured yet.</b> Super-admin needs to paste Stripe credentials at <a href="/admin_config.cgi" style="color:#fbbf24">/admin_config.cgi</a> &rarr; Stripe section. See STRIPE_SETUP.md for the step-by-step.';
311 $body .= '</div>';
312}
313
314# Existing cards
315if (@cards) {
316 $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
317 $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:12px">Saved cards</div>';
318 foreach my $c (@cards) {
319 my $brand = _esc($c->{brand} || $c->{card_brand} || 'Card');
320 my $last4 = _esc($c->{last4} || '****');
321 my $exp_m = $c->{exp_month} ? sprintf('%02d', $c->{exp_month}) : '--';
322 my $exp_y = $c->{exp_year} ? substr($c->{exp_year}, -2) : '--';
323 my $is_default = $c->{is_default} ? 1 : 0;
324 my $pm_id = _esc($c->{stripe_payment_method_id} || $c->{id});
325 my $default_pill = $is_default ? '<span style="background:#10b98122;color:#10b981;font-size:11px;padding:3px 9px;border-radius:999px;margin-left:8px;font-weight:600">Default</span>' : '';
326 my $set_default_btn = $is_default ? '' :
327 qq~<form method="POST" style="display:inline"><input type="hidden" name="act" value="set_default"><input type="hidden" name="stripe_payment_method_id" value="$pm_id"><button type="submit" style="background:#1f2a4a;color:#e6ecf6;border:0;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;margin-right:6px">Set as default</button></form>~;
328 my $del_btn = qq~<form method="POST" style="display:inline" onsubmit="return confirm('Remove this card?');"><input type="hidden" name="act" value="delete_card"><input type="hidden" name="stripe_payment_method_id" value="$pm_id"><button type="submit" style="background:transparent;color:#fca5a5;border:0;padding:6px 12px;cursor:pointer;font-size:12px">Remove</button></form>~;
329 $body .= qq~ <div style="display:flex;align-items:center;padding:12px 0;border-bottom:1px dashed #1f2a4a">
330 <div style="flex:1">
331 <span style="color:#e6ecf6;font-size:14px"><b>$brand</b> &middot; &middot;&middot;&middot;&middot; $last4</span>$default_pill
332 <div style="color:#7e92b6;font-size:12px;margin-top:2px">Expires $exp_m/$exp_y</div>
333 </div>
334 <div>$set_default_btn$del_btn</div>
335 </div>~;
336 }
337 $body .= '</section>';
338}
339
340# Add-new-card form (only when Stripe is configured)
341if ($stripe_ready) {
342 my $no_cards_intro = @cards ? 'Add another card' : 'Add your first card';
343 my $pk = _esc($stripe->publishable_key);
344 my $secret = _esc($client_secret);
345 my $next_plan_input = ($need_card && $next_plan)
346 ? qq~<input type="hidden" name="next_plan_id" value="$next_plan">~
347 : '';
348 $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
349 $body .= " <div style=\"font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px\">$no_cards_intro</div>";
350 if (!$client_secret) {
351 $body .= ' <div style="color:#fca5a5;font-size:14px">Could not create a Stripe SetupIntent. Check stripe_secret in <a href="/admin_config.cgi" style="color:#14b8a6">/admin_config.cgi</a>.</div>';
352 } else {
353 $body .= ' <form id="card-form" method="POST" style="display:flex;flex-direction:column;gap:14px">';
354 $body .= ' <input type="hidden" name="act" value="save_card">';
355 $body .= ' <input type="hidden" name="stripe_payment_method_id" id="pm-id-field" value="">';
356 $body .= " $next_plan_input";
357 $body .= ' <div id="card-element" style="padding:14px;background:#0a0f1f;border:1px solid #1f2a4a;border-radius:8px"></div>';
358 $body .= ' <div id="card-errors" role="alert" style="color:#fca5a5;font-size:13px;min-height:18px"></div>';
359 $body .= ' <button id="card-submit" type="submit" style="background:#064e3b;color:#a7f3d0;border:0;padding:10px 18px;border-radius:8px;font-weight:700;cursor:pointer;font-size:14px;width:max-content">Save card</button>';
360 $body .= ' </form>';
361 $body .= ' <div style="color:#7e92b6;font-size:11px;margin-top:10px">PCI handled by Stripe. Use 4242 4242 4242 4242 (any future date, any CVC) for testing.</div>';
362 $body .= <<HTML;
363<script src="https://js.stripe.com/v3/"></script>
364<script>
365(function() {
366 var stripe = Stripe('$pk');
367 var elements = stripe.elements();
368 var card = elements.create('card', {
369 style: {
370 base: {
371 color: '#e6ecf6',
372 fontSize: '15px',
373 fontFamily: 'system-ui, -apple-system, sans-serif',
374 '::placeholder': { color: '#7e92b6' }
375 },
376 invalid: { color: '#fca5a5', iconColor: '#fca5a5' }
377 }
378 });
379 card.mount('#card-element');
380 card.on('change', function(event) {
381 var errEl = document.getElementById('card-errors');
382 errEl.textContent = event.error ? event.error.message : '';
383 });
384 var form = document.getElementById('card-form');
385 var btn = document.getElementById('card-submit');
386 form.addEventListener('submit', function(ev) {
387 ev.preventDefault();
388 btn.disabled = true;
389 btn.textContent = 'Saving...';
390 stripe.confirmCardSetup('$secret', {
391 payment_method: { card: card }
392 }).then(function(result) {
393 if (result.error) {
394 document.getElementById('card-errors').textContent = result.error.message;
395 btn.disabled = false;
396 btn.textContent = 'Save card';
397 return;
398 }
399 document.getElementById('pm-id-field').value = result.setupIntent.payment_method;
400 form.submit();
401 });
402 });
403})();
404</script>
405HTML
406 }
407 $body .= '</section>';
408}
409
410# Back link
411$body .= '<div style="margin-top:20px"><a href="/billing.cgi" style="color:#14b8a6;font-size:13px">&larr; Back to billing</a></div>';
412$body .= '</div>';
413
414print "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";
415$wrap->render({
416 userinfo => $userinfo,
417 page_key => 'billing',
418 title => 'Manage card on file',
419 body => $body,
420});
421
422sub _redirect {
423 my $url = shift;
424 print "Status: 302 Found\nLocation: $url\n\n";
425}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help