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

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

added on local at 2026-07-01 15:02:39

Added
+300
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to a998d5775d14
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# ContactForge - 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` rep-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/crm.3dshawn.com';
29use CGI;
30use MODS::Template;
31use MODS::DBConnect;
32use MODS::Login;
33use MODS::ContactForge::Config;
34use MODS::ContactForge::Wrapper;
35use MODS::ContactForge::Billing;
36use MODS::ContactForge::Permissions;
37use MODS::ContactForge::Stripe;
38
39my $q = CGI->new;
40my $form = $q->Vars;
41my $auth = MODS::Login->new;
42my $wrap = MODS::ContactForge::Wrapper->new;
43my $tfile = MODS::Template->new;
44my $db = MODS::DBConnect->new;
45my $cfg = MODS::ContactForge::Config->new;
46my $bill = MODS::ContactForge::Billing->new;
47my $stripe = MODS::ContactForge::Stripe->new;
48my $perm = MODS::ContactForge::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 rep 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
286print "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";
287
288my $body = join('', $tfile->template('cf_billing_payment.html', $tvars, $userinfo));
289
290$wrap->render({
291 userinfo => $userinfo,
292 page_key => 'billing',
293 title => 'Manage card on file',
294 body => $body,
295});
296
297sub _redirect {
298 my $url = shift;
299 print "Status: 302 Found\nLocation: $url\n\n";
300}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help