Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/billing.cgi
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/billing.cgi

added on local at 2026-07-01 22:09:29

Added
+886
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 74a86b84fdbc
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# ShopCart - Seller Billing
4#
5# Per-user billing page. Five panels:
6# 1. Current subscription + next charge
7# 2. Credit balance + recent ledger activity
8# 3. Plan picker (upgrade/downgrade) - posts to billing_action.cgi
9# 4. Recent invoices (with credit_applied vs cash_paid split)
10# 5. Payment method (card brand + last4)
11#
12# Credit system: cash is NEVER charged while credit covers the bill.
13# The billing worker (deferred / Stripe Subscriptions API) applies
14# credit before charging. Customer here only sees the read-out.
15#
16# Consolidates the former /billing_action.cgi (POST handler) via
17# SCRIPT_NAME dispatch. The _action.cgi file is a wrapper that
18# `do`s this file.
19#======================================================================
20use strict;
21use warnings;
22
23use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
24use CGI;
25use MODS::Template;
26use MODS::DBConnect;
27use MODS::Login;
28use MODS::ShopCart::Config;
29use MODS::ShopCart::Wrapper;
30use MODS::ShopCart::Billing;
31use MODS::ShopCart::Promotions;
32use MODS::ShopCart::Stripe;
33
34my $q = CGI->new;
35my $form = $q->Vars;
36my $auth = MODS::Login->new;
37my $wrap = MODS::ShopCart::Wrapper->new;
38my $tfile = MODS::Template->new;
39my $db = MODS::DBConnect->new;
40my $cfg = MODS::ShopCart::Config->new;
41my $bill = MODS::ShopCart::Billing->new;
42my $DB = $cfg->settings('database_name');
43
44$|=1;
45my $userinfo = $auth->login_verify();
46
47my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'billing';
48
49if ($entry eq 'billing_action') {
50 if (!$userinfo) {
51 print "Status: 302 Found\nLocation: /login.cgi\n\n";
52 exit;
53 }
54 require MODS::ShopCart::Permissions;
55 # Match billing.cgi's gate: owner or manage_billing ptag holder.
56 MODS::ShopCart::Permissions->new->require_feature(
57 $userinfo, 'manage_billing', '/dashboard.cgi?denied=manage_billing'
58 );
59 _handle_action($q, $form);
60 exit;
61}
62
63if (!$userinfo) {
64 print "Status: 302 Found\nLocation: /login.cgi\n\n";
65 exit;
66}
67require MODS::ShopCart::Permissions;
68# Billing is owner-by-default but can be delegated to a team seat via
69# the `manage_billing` ptag. Owners pass through has_feature's owner
70# short-circuit; team members without the grant bounce to /dashboard.cgi.
71MODS::ShopCart::Permissions->new->require_feature(
72 $userinfo, 'manage_billing', '/dashboard.cgi?denied=manage_billing'
73);
74my $uid = $userinfo->{user_id};
75$uid =~ s/[^0-9]//g;
76
77# Tutorial mode: when ?tutorial=1 we paint an example subscription,
78# credit ledger and invoice history so a brand-new account can see
79# what their billing page reads like once they have history.
80my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
81
82my $dbh = $db->db_connect();
83my $schema_ready = $bill->schema_ready($db, $dbh, $DB);
84
85# Flip any deferred downgrades whose pending_change_at has passed.
86# Cheap: most page-loads see zero rows due. Until the dedicated billing
87# worker exists, this keeps a scheduled downgrade from sitting beyond
88# its anchor date.
89$bill->apply_pending_changes_if_due($db, $dbh, $DB, $uid) if $schema_ready;
90
91# ---- Subscription + credit balance ----------------------------------
92my $sub = $schema_ready ? $bill->active_subscription($db, $dbh, $DB, $uid) : {};
93my $balance = $schema_ready ? $bill->credit_balance_cents($db, $dbh, $DB, $uid) : 0;
94my @ledger = $schema_ready ? $bill->credit_history($db, $dbh, $DB, $uid, 10) : ();
95my @invoices_raw = $schema_ready ? $bill->list_invoices($db, $dbh, $DB, $uid, 24) : ();
96my @pms = $schema_ready ? $bill->list_payment_methods($db, $dbh, $DB, $uid) : ();
97my @all_plans = $schema_ready ? $bill->list_plans($db, $dbh, $DB) : ();
98
99# Group plans by tier so the picker shows one card per tier with both
100# cadence prices side-by-side.
101my %plans_by_tier;
102foreach my $p (@all_plans) {
103 push @{ $plans_by_tier{ $p->{tier} } }, $p;
104}
105
106# Helper: decode plan features JSON for the picker card body.
107sub _parse_features {
108 my $blob = shift;
109 return [] unless defined $blob && $blob ne '';
110 my @out;
111 # Quick & tolerant parser: features ship as
112 # [{"name":"X","included":true}, ...]
113 # Avoid pulling JSON.pm just for this; rows are short and trusted.
114 while ($blob =~ /\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"included"\s*:\s*(true|false)\s*\}/gs) {
115 push @out, { name => $1, included => ($2 eq 'true') ? 1 : 0 };
116 }
117 return \@out;
118}
119
120# Build the plan-tier cards for the template.
121# Resolve the user's "current" tier: an active subscription wins, but
122# free users have no subscription row, so fall back to users.plan_tier
123# from the login session so the Free card still highlights for them.
124my $current_tier = ($sub && $sub->{tier})
125 ? $sub->{tier}
126 : ($userinfo->{plan_tier} || 'free');
127
128my @plan_cards;
129foreach my $tier (qw(free pro business)) {
130 my $variants = $plans_by_tier{$tier} || [];
131 next unless @$variants;
132 my $monthly = (grep { $_->{cadence} eq 'monthly' } @$variants)[0];
133 my $annual = (grep { $_->{cadence} eq 'annual' } @$variants)[0];
134 my $price_m_cents = $monthly ? $monthly->{price_cents} : 0;
135 my $price_y_cents = $annual ? $annual->{price_cents} : 0;
136 my $features = _parse_features($monthly ? $monthly->{features} : '');
137 my $is_current = ($current_tier eq $tier) ? 1 : 0;
138
139 # Quote the switch into THIS plan so the confirm modal can be
140 # specific about money: "Charged $17.00 now (prorated)..." rather
141 # than the generic "upgrades take effect now" copy. Skipped for
142 # the user's own current tier since there's no button anyway.
143 my ($confirm_title, $confirm_message, $confirm_style, $confirm_label);
144 if (!$is_current && $monthly && $monthly->{id}) {
145 my $q = $bill->quote_plan_change($db, $dbh, $DB, $uid, $monthly->{id}, 'monthly');
146 my $charge_today = $q->{charge_today_cents} || 0;
147 my $proration = $q->{proration_credit_cents} || 0;
148 my $renewal = $q->{renewal_amount_cents} || 0;
149 my $cls = $q->{classification} || 'immediate';
150
151 if ($tier eq 'free') {
152 # Cancel-to-free path. Always deferred to end of cycle.
153 $confirm_title = 'Cancel paid plan and move to Free?';
154 $confirm_message = 'You stay on your current plan until the end of the cycle, then drop to Free automatically. No further charges.';
155 $confirm_label = 'Move to Free';
156 $confirm_style = 'warning';
157 } elsif ($cls eq 'deferred') {
158 $confirm_title = 'Schedule downgrade to ' . ucfirst($tier) . '?';
159 $confirm_message = 'You keep your current plan until the end of this cycle, then switch to ' . ucfirst($tier) . ' at ' . $bill->fmt_money($renewal) . '/month. No charge today.';
160 $confirm_label = 'Schedule downgrade';
161 $confirm_style = 'warning';
162 } else {
163 my $charge_str = $bill->fmt_money($charge_today);
164 my $renew_str = $bill->fmt_money($renewal);
165 $confirm_title = 'Switch to the ' . ucfirst($tier) . ' plan?';
166 if ($proration > 0) {
167 $confirm_message =
168 "You'll be charged $charge_str today. That's the $renew_str price minus a "
169 . $bill->fmt_money($proration)
170 . ' credit for the unused days of your current plan. Next renewal: ' . $renew_str . '/month.';
171 } elsif ($q->{needs_payment_method}) {
172 $confirm_message =
173 "You'll be charged $charge_str today, then $renew_str/month going forward. "
174 . 'You will be prompted to add a payment method first.';
175 } else {
176 $confirm_message =
177 "You'll be charged $charge_str today, then $renew_str/month going forward. "
178 . 'The charge runs against your card on file.';
179 }
180 $confirm_label = 'Switch to ' . ucfirst($tier) . ' · charge ' . $charge_str;
181 $confirm_style = 'primary';
182 }
183 } else {
184 $confirm_title = '';
185 $confirm_message = '';
186 $confirm_label = '';
187 $confirm_style = 'primary';
188 }
189
190 push @plan_cards, {
191 tier => $tier,
192 tier_label => ucfirst($tier),
193 display_name => $monthly ? $monthly->{display_name} : ucfirst($tier),
194 price_monthly => $bill->fmt_money($price_m_cents),
195 price_annual => $bill->fmt_money($price_y_cents),
196 is_free => ($tier eq 'free') ? 1 : 0,
197 price_m_cents => $price_m_cents,
198 price_y_cents => $price_y_cents,
199 plan_id_monthly => $monthly ? $monthly->{id} : 0,
200 plan_id_annual => $annual ? $annual->{id} : 0,
201 features => $features,
202 is_current => $is_current,
203 confirm_title => $confirm_title,
204 confirm_message => $confirm_message,
205 confirm_label => $confirm_label,
206 confirm_style => $confirm_style,
207 };
208}
209
210# Format invoice rows for the table.
211my @invoices;
212foreach my $r (@invoices_raw) {
213 my $status = $r->{status} || 'open';
214 push @invoices, {
215 id => $r->{id},
216 invoice_number => $r->{invoice_number} || ('INV-' . $r->{id}),
217 period_label => _format_period($r->{period_start}, $r->{period_end}),
218 amount_due_display => $bill->fmt_money($r->{amount_due_cents} || 0),
219 credit_applied_display => $bill->fmt_money($r->{credit_applied_cents} || 0),
220 cash_paid_display => $bill->fmt_money($r->{cash_paid_cents} || 0),
221 has_credit_applied => (($r->{credit_applied_cents} || 0) > 0) ? 1 : 0,
222 status => $status,
223 status_label => _status_label($status),
224 is_paid => $status eq 'paid' ? 1 : 0,
225 is_open => $status eq 'open' ? 1 : 0,
226 is_failed => $status eq 'failed' ? 1 : 0,
227 is_void => $status eq 'void' ? 1 : 0,
228 is_refunded => $status eq 'refunded' ? 1 : 0,
229 issued_at => $r->{issued_at} || $r->{created_at} || '',
230 paid_at => $r->{paid_at} || '-',
231 failure_reason => _h($r->{failure_reason} || ''),
232 has_failure => ($r->{failure_reason} && $status eq 'failed') ? 1 : 0,
233 };
234}
235
236# Credit-ledger rows for the activity feed.
237my @credit_rows;
238foreach my $r (@ledger) {
239 my $amt = $r->{amount_cents} || 0;
240 push @credit_rows, {
241 id => $r->{id},
242 amount_display => $bill->fmt_money($amt),
243 is_positive => ($amt >= 0) ? 1 : 0,
244 is_negative => ($amt < 0) ? 1 : 0,
245 reason => $r->{reason},
246 reason_label => _reason_label($r->{reason}),
247 note => _h($r->{note} || ''),
248 has_note => ($r->{note} && $r->{note} ne '') ? 1 : 0,
249 invoice_id => $r->{related_invoice_id} || 0,
250 created_at => $r->{created_at} || '',
251 };
252}
253
254# Default payment method.
255my %pm;
256foreach my $p (@pms) {
257 if ($p->{is_default} || !%pm) { %pm = %$p; }
258}
259my $has_pm = %pm ? 1 : 0;
260my $pm_brand_label = $has_pm ? (ucfirst($pm{brand} || 'card')) : '';
261my $pm_last4 = $has_pm ? ($pm{last4} || '----') : '';
262my $pm_exp = $has_pm ? sprintf('%02d/%02d', ($pm{exp_month} || 0), (($pm{exp_year} || 0) % 100)) : '';
263
264# Subscription display fields.
265my $has_sub = ($sub && $sub->{id}) ? 1 : 0;
266my $sub_status = $has_sub ? $sub->{status} : 'none';
267my $sub_plan_label = $has_sub ? $sub->{display_name} : 'Free';
268my $sub_tier = $has_sub ? $sub->{tier} : 'free';
269my $sub_cadence = $has_sub ? $sub->{cadence} : 'monthly';
270my $sub_price = $has_sub ? $bill->fmt_money($sub->{price_cents}) : '$0.00';
271my $sub_cadence_lbl = $bill->fmt_cadence($sub_cadence);
272my $sub_next_charge = $has_sub ? ($sub->{next_invoice_at} || 'not scheduled') : 'no upcoming charge';
273my $sub_is_canceling = ($has_sub && $sub->{cancel_at_period_end}) ? 1 : 0;
274my $sub_is_past_due = ($has_sub && $sub_status eq 'past_due') ? 1 : 0;
275my $sub_is_trialing = ($has_sub && $sub_status eq 'trialing') ? 1 : 0;
276
277# Pending plan change (scheduled downgrade). active_subscription() pulls
278# pending_plan_display_name / pending_plan_tier / pending_change_at when
279# the migration is in place; falsy when nothing is queued.
280my $has_pending_change = ($has_sub && $sub->{pending_plan_id}) ? 1 : 0;
281my $pending_plan_label = $has_pending_change ? ($sub->{pending_plan_display_name} || ucfirst($sub->{pending_plan_tier} || '')) : '';
282my $pending_change_at = $has_pending_change ? ($sub->{pending_change_at} || '') : '';
283my $pending_cadence_lbl = $has_pending_change ? ($bill->fmt_cadence($sub->{pending_cadence} || $sub->{cadence})) : '';
284
285# Estimate of the next-charge cash + credit split. Pure preview;
286# the worker recomputes against actual balance at invoice time.
287my $next_charge_cents = $has_sub ? ($sub->{price_cents} || 0) : 0;
288my $next_credit_applied = ($balance >= $next_charge_cents)
289 ? $next_charge_cents
290 : ($balance > 0 ? $balance : 0);
291my $next_cash_due = $next_charge_cents - $next_credit_applied;
292$next_cash_due = 0 if $next_cash_due < 0;
293
294$db->db_disconnect($dbh);
295
296# Active plan promo (for the marketing banner on the same panel; also
297# pre-fills the input so the seller can just click Apply).
298my $promo_obj = MODS::ShopCart::Promotions->new;
299my $active_promo = $schema_ready ? $promo_obj->active_plan_promo($db, $dbh, $DB) : undef;
300my $promo_banner_code = ($active_promo && $active_promo->{code}) ? $active_promo->{code} : '';
301my $promo_banner_label = $active_promo ? $promo_obj->marketing_label($active_promo) : '';
302
303# Flash messages for the promo-code panel (set by billing_action.cgi
304# via ?promo_ok / ?promo_err query params on redirect).
305my $promo_flash_msg = '';
306my $promo_flash_kind = '';
307if (defined $form->{promo_ok} && length $form->{promo_ok}) {
308 my $cents = $form->{promo_ok}; $cents =~ s/[^0-9]//g; $cents = 0 unless $cents;
309 $promo_flash_kind = 'ok';
310 $promo_flash_msg = $cents
311 ? 'Code applied -- ' . $bill->fmt_money($cents) . ' in credit added. It will be used on your next invoice automatically.'
312 : 'Code accepted. Once you are on a paid plan, the discount will apply to your next invoice.';
313}
314if (defined $form->{promo_err} && length $form->{promo_err}) {
315 my $r = $form->{promo_err};
316 $r =~ s/[^A-Za-z0-9 .,;:_\-\$()%']//g;
317 $r = substr($r, 0, 200);
318 $promo_flash_kind = 'danger';
319 $promo_flash_msg = $r;
320}
321
322# Resume-upgrade hint: user just added a card after being bounced from
323# the change_plan flow. Quote the pending plan so we can render a one-
324# click "Complete upgrade" CTA at the top of the page.
325my $resume_plan = $form->{resume_plan} || '';
326$resume_plan =~ s/[^0-9]//g;
327my $resume_quote;
328my $resume_plan_label = '';
329my $resume_charge_str = '';
330if ($resume_plan) {
331 $resume_quote = $bill->quote_plan_change($db, $dbh, $DB, $uid, $resume_plan, '');
332 if ($resume_quote && ($resume_quote->{charge_today_cents} || 0) > 0) {
333 my $rp = $bill->plan_by_id($db, $dbh, $DB, $resume_plan);
334 $resume_plan_label = ($rp && $rp->{display_name}) ? $rp->{display_name} : 'paid';
335 $resume_charge_str = $bill->fmt_money($resume_quote->{charge_today_cents});
336 } else {
337 $resume_plan = '';
338 }
339}
340
341# Plan-change / charge flash. Same banner mechanism as the promo one,
342# just keyed off ?ok=plan_changed_charged or ?err=charge_failed.
343my $plan_flash_msg = '';
344my $plan_flash_kind = '';
345my $okv = defined $form->{ok} ? $form->{ok} : '';
346my $errv = defined $form->{err} ? $form->{err} : '';
347if ($okv eq 'plan_changed_charged') {
348 my $amt = $form->{amount}; $amt =~ s/[^0-9]//g if defined $amt; $amt = 0 unless $amt;
349 $plan_flash_kind = 'ok';
350 $plan_flash_msg = $amt
351 ? ('Plan upgraded and ' . $bill->fmt_money($amt) . ' charged to your card. Your new features are active right now.')
352 : 'Plan upgraded. Your new features are active right now.';
353} elsif ($okv eq 'plan_changed') {
354 $plan_flash_kind = 'ok';
355 $plan_flash_msg = 'Plan updated.';
356} elsif ($okv eq 'plan_scheduled') {
357 my $when = $form->{when}; $when =~ s/[^0-9\-: ]//g if defined $when;
358 $plan_flash_kind = 'ok';
359 $plan_flash_msg = 'Downgrade scheduled' . ($when ? " for $when." : '. It takes effect at the end of your current cycle.');
360} elsif ($errv eq 'charge_failed') {
361 my $detail = $form->{detail} || '';
362 $detail =~ s/[^A-Za-z0-9 .,;:_\-\$()%'#]//g;
363 $detail = substr($detail, 0, 240);
364 $plan_flash_kind = 'danger';
365 $plan_flash_msg = $detail
366 ? "Charge failed: $detail Your plan was NOT changed. Try a different card or contact support."
367 : 'Charge failed. Your plan was NOT changed. Try a different card or contact support.';
368} elsif ($errv eq 'plan_change_failed') {
369 $plan_flash_kind = 'danger';
370 $plan_flash_msg = 'Could not change your plan. Please try again or contact support.';
371}
372
373my $tvars = {
374 user_id => $uid,
375 promo_flash_msg => $promo_flash_msg,
376 promo_flash_kind => $promo_flash_kind,
377 has_promo_flash => length $promo_flash_msg ? 1 : 0,
378 plan_flash_msg => $plan_flash_msg,
379 plan_flash_kind => $plan_flash_kind,
380 plan_flash_is_ok => ($plan_flash_kind eq 'ok') ? 1 : 0,
381 plan_flash_is_err => ($plan_flash_kind eq 'danger') ? 1 : 0,
382 has_plan_flash => length $plan_flash_msg ? 1 : 0,
383 has_resume_plan => $resume_plan ? 1 : 0,
384 resume_plan_id => $resume_plan,
385 resume_plan_label => $resume_plan_label,
386 resume_charge_str => $resume_charge_str,
387 has_active_promo => $active_promo ? 1 : 0,
388 active_promo_code => $promo_banner_code,
389 active_promo_label => $promo_banner_label,
390
391 # Subscription
392 has_sub => $has_sub,
393 sub_status => $sub_status,
394 sub_status_label => _status_label($sub_status),
395 sub_plan_label => $sub_plan_label,
396 sub_tier => $sub_tier,
397 sub_tier_label => ucfirst($sub_tier || 'free'),
398 sub_cadence => $sub_cadence,
399 sub_cadence_label => $sub_cadence_lbl,
400 sub_price => $sub_price,
401 sub_next_charge => $sub_next_charge,
402 sub_is_canceling => $sub_is_canceling,
403 sub_is_past_due => $sub_is_past_due,
404 sub_is_trialing => $sub_is_trialing,
405
406 # Pending downgrade banner
407 has_pending_change => $has_pending_change,
408 pending_plan_label => $pending_plan_label,
409 pending_change_at => $pending_change_at,
410 pending_cadence_label => $pending_cadence_lbl,
411
412 next_charge_display => $bill->fmt_money($next_charge_cents),
413 next_credit_display => $bill->fmt_money($next_credit_applied),
414 next_cash_display => $bill->fmt_money($next_cash_due),
415 has_next_credit_applied => ($next_credit_applied > 0) ? 1 : 0,
416 next_cash_due_zero => ($next_cash_due == 0 && $next_charge_cents > 0) ? 1 : 0,
417
418 # Credit
419 credit_balance_display => $bill->fmt_money($balance),
420 credit_balance_cents => $balance,
421 has_credit_balance => ($balance > 0) ? 1 : 0,
422 credit_rows => \@credit_rows,
423 has_credit_history => scalar(@credit_rows) ? 1 : 0,
424
425 # Plans
426 plan_cards => \@plan_cards,
427 has_plans => scalar(@plan_cards) ? 1 : 0,
428
429 # Invoices
430 invoices => \@invoices,
431 has_invoices => scalar(@invoices) ? 1 : 0,
432
433 # Payment method
434 has_pm => $has_pm,
435 pm_brand_label => $pm_brand_label,
436 pm_last4 => $pm_last4,
437 pm_exp => $pm_exp,
438
439 # Schema readiness banner
440 schema_ready => $schema_ready ? 1 : 0,
441 schema_missing => $schema_ready ? 0 : 1,
442
443 # Tutorial mode
444 tutorial_mode => $tutorial_mode,
445 not_tutorial_mode => $tutorial_mode ? 0 : 1,
446};
447
448# ---- Tutorial-mode overlay -----------------------------------------
449# Painted only when ?tutorial=1. No DB; illustrative.
450if ($tutorial_mode) {
451 $tvars->{has_sub} = 1;
452 $tvars->{sub_status} = 'active';
453 $tvars->{sub_status_label} = 'Active';
454 $tvars->{sub_plan_label} = 'Pro';
455 $tvars->{sub_tier} = 'pro';
456 $tvars->{sub_tier_label} = 'Pro';
457 $tvars->{sub_cadence} = 'monthly';
458 $tvars->{sub_cadence_label} = 'month';
459 $tvars->{sub_price} = '$29.00';
460 $tvars->{sub_next_charge} = '2026-06-12';
461 $tvars->{sub_is_canceling} = 0;
462 $tvars->{sub_is_past_due} = 0;
463 $tvars->{sub_is_trialing} = 0;
464
465 $tvars->{next_charge_display} = '$29.00';
466 $tvars->{next_credit_display} = '$12.00';
467 $tvars->{next_cash_display} = '$17.00';
468 $tvars->{has_next_credit_applied} = 1;
469 $tvars->{next_cash_due_zero} = 0;
470
471 $tvars->{credit_balance_display} = '$12.00';
472 $tvars->{credit_balance_cents} = 1200;
473 $tvars->{has_credit_balance} = 1;
474 $tvars->{credit_rows} = [
475 { id=>3, amount_display=>'$25.00', is_positive=>1, is_negative=>0,
476 reason=>'refund', reason_label=>'Refund as credit',
477 note=>'Refund for April invoice issue (admin-issued).', has_note=>1,
478 invoice_id=>0, created_at=>'2026-05-02' },
479 { id=>2, amount_display=>'-$13.00', is_positive=>0, is_negative=>1,
480 reason=>'applied_to_invoice', reason_label=>'Applied to invoice INV-0001247-202605',
481 note=>'', has_note=>0,
482 invoice_id=>0, created_at=>'2026-05-12' },
483 { id=>1, amount_display=>'$10.00', is_positive=>1, is_negative=>0,
484 reason=>'promotional', reason_label=>'Promotional credit',
485 note=>'Welcome bonus for upgrading to Pro.', has_note=>1,
486 invoice_id=>0, created_at=>'2026-03-01' },
487 ];
488 $tvars->{has_credit_history} = 1;
489
490 # Pretend plans for the picker. Pro = current.
491 $tvars->{plan_cards} = [
492 { tier=>'free', tier_label=>'Free', display_name=>'Free',
493 price_monthly=>'$0.00', price_annual=>'$0.00',
494 is_free=>1, price_m_cents=>0, price_y_cents=>0,
495 plan_id_monthly=>1, plan_id_annual=>2,
496 features=>[
497 { name=>'Branded storefront with cart + checkout', included=>1 },
498 { name=>'Up to 50 products', included=>1 },
499 { name=>'Per-file upload up to 25 MB', included=>1 },
500 { name=>'1 team seat', included=>1 },
501 { name=>'1 active A/B test', included=>1 },
502 { name=>'Abandoned-cart recovery + built-in CRM', included=>1 },
503 { name=>'0% platform fee on every sale', included=>1 },
504 ],
505 is_current=>0 },
506 { tier=>'pro', tier_label=>'Pro', display_name=>'Pro',
507 price_monthly=>'$29.00', price_annual=>'$290.00',
508 is_free=>0, price_m_cents=>2900, price_y_cents=>29000,
509 plan_id_monthly=>3, plan_id_annual=>4,
510 features=>[
511 { name=>'Everything in Free', included=>1 },
512 { name=>'Unlimited products', included=>1 },
513 { name=>'Per-file upload up to 500 MB', included=>1 },
514 { name=>'5 team seats', included=>1 },
515 { name=>'10 active A/B tests + 3 MVT block tests', included=>1 },
516 { name=>'Van Westendorp price surveys', included=>1 },
517 { name=>'Custom domain', included=>1 },
518 { name=>'Priority support', included=>1 },
519 ],
520 is_current=>1 },
521 { tier=>'business', tier_label=>'Business', display_name=>'Business',
522 price_monthly=>'$79.00', price_annual=>'$790.00',
523 is_free=>0, price_m_cents=>7900, price_y_cents=>79000,
524 plan_id_monthly=>5, plan_id_annual=>6,
525 features=>[
526 { name=>'Everything in Pro', included=>1 },
527 { name=>'Per-file upload up to 5 GB', included=>1 },
528 { name=>'Unlimited team seats', included=>1 },
529 { name=>'Unlimited A/B + MVT', included=>1 },
530 { name=>'Price bandits', included=>1 },
531 { name=>'White-label checkout', included=>1 },
532 { name=>'Dedicated onboarding', included=>1 },
533 { name=>'SLA-backed support', included=>1 },
534 ],
535 is_current=>0 },
536 ];
537 $tvars->{has_plans} = 1;
538
539 $tvars->{invoices} = [
540 { id=>4, invoice_number=>'INV-0001247-202605',
541 period_label=>'May 12 - Jun 11, 2026',
542 amount_due_display=>'$29.00', credit_applied_display=>'$12.00',
543 cash_paid_display=>'$17.00', has_credit_applied=>1,
544 status=>'paid', status_label=>'Paid',
545 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
546 issued_at=>'2026-05-12', paid_at=>'2026-05-12',
547 failure_reason=>'', has_failure=>0 },
548 { id=>3, invoice_number=>'INV-0001247-202604',
549 period_label=>'Apr 12 - May 11, 2026',
550 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
551 cash_paid_display=>'$29.00', has_credit_applied=>0,
552 status=>'refunded', status_label=>'Refunded as credit',
553 is_paid=>0, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>1,
554 issued_at=>'2026-04-12', paid_at=>'2026-04-12',
555 failure_reason=>'', has_failure=>0 },
556 { id=>2, invoice_number=>'INV-0001247-202603',
557 period_label=>'Mar 12 - Apr 11, 2026',
558 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
559 cash_paid_display=>'$29.00', has_credit_applied=>0,
560 status=>'paid', status_label=>'Paid',
561 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
562 issued_at=>'2026-03-12', paid_at=>'2026-03-12',
563 failure_reason=>'', has_failure=>0 },
564 { id=>1, invoice_number=>'INV-0001247-202602',
565 period_label=>'Feb 12 - Mar 11, 2026',
566 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
567 cash_paid_display=>'$29.00', has_credit_applied=>0,
568 status=>'paid', status_label=>'Paid',
569 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
570 issued_at=>'2026-02-12', paid_at=>'2026-02-12',
571 failure_reason=>'', has_failure=>0 },
572 ];
573 $tvars->{has_invoices} = 1;
574
575 $tvars->{has_pm} = 1;
576 $tvars->{pm_brand_label} = 'Visa';
577 $tvars->{pm_last4} = '4242';
578 $tvars->{pm_exp} = '08/29';
579
580 $tvars->{schema_ready} = 1;
581 $tvars->{schema_missing} = 0;
582}
583
584print "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";
585
586my $body = join('', $tfile->template('shopcart_billing.html', $tvars, $userinfo));
587
588$wrap->render({
589 userinfo => $userinfo,
590 page_key => 'billing',
591 title => 'Billing',
592 body => $body,
593});
594
595# ----------------------------------------------------------------- helpers
596sub _format_period {
597 my ($s, $e) = @_;
598 return '-' unless ($s || $e);
599 return ($s || '') . ' - ' . ($e || '');
600}
601
602sub _status_label {
603 my $s = shift || '';
604 return 'Active' if $s eq 'active';
605 return 'Trialing' if $s eq 'trialing';
606 return 'Past due' if $s eq 'past_due';
607 return 'Canceled' if $s eq 'canceled';
608 return 'Paused' if $s eq 'paused';
609 return 'Paid' if $s eq 'paid';
610 return 'Open' if $s eq 'open';
611 return 'Failed' if $s eq 'failed';
612 return 'Void' if $s eq 'void';
613 return 'Refunded as credit' if $s eq 'refunded';
614 return ucfirst($s);
615}
616
617sub _reason_label {
618 my $r = shift || '';
619 return 'Refund as credit' if $r eq 'refund';
620 return 'Manual credit grant' if $r eq 'manual_grant';
621 return 'Promotional credit' if $r eq 'promotional';
622 return 'Applied to invoice' if $r eq 'applied_to_invoice';
623 return 'Reversal' if $r eq 'reversal';
624 return 'Expired' if $r eq 'expired';
625 return ucfirst($r);
626}
627
628sub _h {
629 my $s = shift;
630 $s //= '';
631 $s =~ s/&/&amp;/g;
632 $s =~ s/</&lt;/g;
633 $s =~ s/>/&gt;/g;
634 $s =~ s/"/&quot;/g;
635 return $s;
636}
637
638#======================================================================
639# billing_action entry: POST handler
640# act=change_plan - upgrade (immediate) or downgrade
641# (deferred to current cycle end)
642# via MODS::ShopCart::Billing::change_plan
643# act=clear_pending_change - cancel a queued deferred downgrade
644# act=cancel_subscription - mark cancel_at_period_end=1
645# act=resume_subscription - undo a pending cancellation
646# act=apply_promo_code - redeem a plan-scope promo code
647# act=update_payment_method - legacy alias, bounces to /billing_payment.cgi
648# act=add_payment_method - legacy alias, bounces to /billing_payment.cgi
649# Every action redirects back to /billing.cgi (or /billing_payment.cgi)
650# with a status flash.
651#======================================================================
652sub _handle_action {
653 my ($q, $form) = @_;
654
655 my $uid = $userinfo->{user_id};
656 $uid =~ s/[^0-9]//g;
657
658 my $act = defined $form->{act} ? $form->{act} : '';
659
660 my $dbh = $db->db_connect();
661 unless ($bill->schema_ready($db, $dbh, $DB)) {
662 $db->db_disconnect($dbh);
663 _act_redirect('/billing.cgi?err=schema');
664 return;
665 }
666
667 if ($act eq 'apply_promo_code') {
668 # User typed a code into the "Have a promo code?" panel on
669 # /billing.cgi. We validate against the platform promotions table,
670 # compute the discount against the user's CURRENT subscription
671 # price (the cleanest reference -- if they're on Free we still
672 # allow the apply but the discount lands at $0 with a hint to
673 # upgrade), then grant the equivalent as promotional credit
674 # via the existing credit_ledger flow. The credit auto-applies on
675 # the next invoice -- no second handoff needed.
676 my $code = defined $form->{promo_code} ? $form->{promo_code} : '';
677 my $promo = MODS::ShopCart::Promotions->new;
678
679 my $row = $promo->load_by_code($db, $dbh, $DB, $code, kind => 'plan');
680 unless ($row && $row->{id}) {
681 $db->db_disconnect($dbh);
682 _act_redirect('/billing.cgi?promo_err=' . _act_u('That code is not valid for plan upgrades.'));
683 return;
684 }
685
686 # Reference price for the discount math. Falls back to 0 on Free.
687 # We require an active paid subscription before applying -- otherwise
688 # the discount would be $0 and the user would be confused.
689 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
690 my $ref_cents = ($sub && $sub->{price_cents}) ? $sub->{price_cents} : 0;
691 unless ($ref_cents > 0) {
692 $db->db_disconnect($dbh);
693 _act_redirect('/billing.cgi?promo_err=' . _act_u('Upgrade to a paid plan first, then apply your promo code.'));
694 return;
695 }
696
697 # Per-plan eligibility check. A promo with zero rows in
698 # promotion_plans applies to every paid plan; one with explicit
699 # rows must include the user's current plan.
700 my $plan_now = ($sub && $sub->{plan_id})
701 ? $bill->plan_by_id($db, $dbh, $DB, $sub->{plan_id})
702 : undef;
703 unless ($plan_now && $promo->promo_applies_to_plan($db, $dbh, $DB, $row->{id}, $plan_now)) {
704 $db->db_disconnect($dbh);
705 _act_redirect('/billing.cgi?promo_err=' . _act_u("That code isn't valid on your current plan. Switch to a covered plan first, then re-apply."));
706 return;
707 }
708
709 my %v = $promo->validate($db, $dbh, $DB, $row,
710 kind => 'plan',
711 user_id => $uid,
712 subtotal_cents => $ref_cents,
713 new_customer => 1, # plan promos don't gate by orders
714 );
715 unless ($v{ok}) {
716 $db->db_disconnect($dbh);
717 _act_redirect('/billing.cgi?promo_err=' . _act_u($v{reason} || 'That code is not valid right now.'));
718 return;
719 }
720
721 my $disc = $promo->apply_to_total_cents($row, $ref_cents);
722 my $rid = $promo->redeem($db, $dbh, $DB,
723 promotion_id => $row->{id},
724 target_kind => 'plan',
725 user_id => $uid,
726 subscription_id => ($sub && $sub->{id}) ? $sub->{id} : undef,
727 invoice_id => undef, # no invoice generated yet
728 discount_applied_cents => $disc,
729 );
730 unless ($rid) {
731 $db->db_disconnect($dbh);
732 _act_redirect('/billing.cgi?promo_err=' . _act_u('Could not redeem the code. Please try again.'));
733 return;
734 }
735
736 # Convert the discount into promotional credit so the existing
737 # credit-applies-to-next-invoice machinery handles the rest.
738 if ($disc > 0) {
739 my $note = 'Promotion ' . ($row->{code} || ('#' . $row->{id}));
740 $bill->grant_credit($db, $dbh, $DB,
741 user_id => $uid,
742 amount_cents => $disc,
743 reason => 'promotional',
744 note => $note,
745 related_invoice_id => undef,
746 granted_by_user_id => undef,
747 );
748 }
749
750 $db->db_disconnect($dbh);
751 _act_redirect('/billing.cgi?promo_ok=' . $disc);
752 return;
753 }
754
755 if ($act eq 'change_plan') {
756 my $plan_id = defined $form->{plan_id} ? $form->{plan_id} : '';
757 $plan_id =~ s/[^0-9]//g;
758 unless ($plan_id) {
759 $db->db_disconnect($dbh);
760 _act_redirect('/billing.cgi?err=bad_plan');
761 return;
762 }
763
764 # Caller may optionally pass a cadence override; otherwise the
765 # helper inherits the current subscription's cadence.
766 my $cadence = defined $form->{cadence} ? $form->{cadence} : '';
767 $cadence = 'annual' if $cadence eq 'annual';
768 $cadence = '' unless $cadence eq 'annual' || $cadence eq 'monthly';
769
770 # Quote the change so we can branch on "charge required" vs
771 # "free downgrade / deferred". Upgrades with money owed go
772 # through charge-first flow; everything else takes the legacy
773 # change_plan() path (no money to move).
774 my $quote = $bill->quote_plan_change($db, $dbh, $DB, $uid, $plan_id, $cadence);
775
776 if (($quote->{charge_today_cents} || 0) > 0) {
777 # Real-money upgrade. Must have a payment method on file --
778 # otherwise bounce to /billing_payment.cgi so they add one
779 # then come back and confirm.
780 if ($quote->{needs_payment_method}) {
781 $db->db_disconnect($dbh);
782 _act_redirect('/billing_payment.cgi?need_card=1&next_plan=' . $plan_id);
783 return;
784 }
785 my $stripe = MODS::ShopCart::Stripe->new;
786 my $result = $bill->change_plan_with_charge(
787 $db, $dbh, $DB, $uid, $plan_id, $cadence, $stripe
788 );
789 $db->db_disconnect($dbh);
790 if (!$result || !$result->{ok}) {
791 my $reason = ($result && $result->{reason}) ? $result->{reason} : 'unknown';
792 my $msg = ($result && $result->{error}) ? $result->{error} : '';
793 if ($reason eq 'no_pm') {
794 _act_redirect('/billing_payment.cgi?need_card=1&next_plan=' . $plan_id);
795 } else {
796 _act_redirect('/billing.cgi?err=charge_failed&reason=' . _act_u($reason)
797 . ($msg ? '&detail=' . _act_u($msg) : ''));
798 }
799 return;
800 }
801 _act_redirect('/billing.cgi?ok=plan_changed_charged&amount=' . ($result->{amount_cents} || 0));
802 return;
803 }
804
805 # Zero-cost path: noop, free downgrade, or deferred downgrade.
806 # Hand off to the legacy change_plan() which already handles
807 # those cases.
808 my $result = $bill->change_plan($db, $dbh, $DB, $uid, $plan_id, $cadence);
809 $db->db_disconnect($dbh);
810
811 if (!$result || !$result->{mode}) {
812 _act_redirect('/billing.cgi?err=plan_change_failed');
813 return;
814 }
815 if ($result->{mode} eq 'immediate') {
816 _act_redirect('/billing.cgi?ok=plan_changed');
817 return;
818 }
819 if ($result->{mode} eq 'deferred') {
820 my $when = defined $result->{effective_at} ? $result->{effective_at} : '';
821 _act_redirect('/billing.cgi?ok=plan_scheduled&when=' . _act_u($when));
822 return;
823 }
824 # mode=noop -- same plan re-selected. Quiet redirect.
825 _act_redirect('/billing.cgi');
826 return;
827 }
828
829 if ($act eq 'clear_pending_change') {
830 $bill->clear_pending_change($db, $dbh, $DB, $uid);
831 $db->db_disconnect($dbh);
832 _act_redirect('/billing.cgi?ok=pending_cleared');
833 return;
834 }
835
836 if ($act eq 'cancel_subscription') {
837 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
838 if ($sub && $sub->{id}) {
839 $db->db_readwrite($dbh, qq~
840 UPDATE `${DB}`.subscriptions
841 SET cancel_at_period_end=1
842 WHERE id='$sub->{id}' AND user_id='$uid'
843 ~, $ENV{SCRIPT_NAME}, __LINE__);
844 }
845 $db->db_disconnect($dbh);
846 _act_redirect('/billing.cgi?ok=canceled');
847 return;
848 }
849
850 if ($act eq 'resume_subscription') {
851 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
852 if ($sub && $sub->{id}) {
853 $db->db_readwrite($dbh, qq~
854 UPDATE `${DB}`.subscriptions
855 SET cancel_at_period_end=0,
856 status='active'
857 WHERE id='$sub->{id}' AND user_id='$uid'
858 ~, $ENV{SCRIPT_NAME}, __LINE__);
859 }
860 $db->db_disconnect($dbh);
861 _act_redirect('/billing.cgi?ok=resumed');
862 return;
863 }
864
865 if ($act eq 'update_payment_method' || $act eq 'add_payment_method') {
866 # Card management now lives on /billing_payment.cgi (Stripe Elements
867 # SetupIntent flow). Old buttons cached in browser tabs end up here;
868 # bounce them to the real page.
869 $db->db_disconnect($dbh);
870 _act_redirect('/billing_payment.cgi');
871 return;
872 }
873
874 $db->db_disconnect($dbh);
875 _act_redirect('/billing.cgi?err=unknown_act');
876}
877
878sub _act_redirect {
879 my $url = shift;
880 print "Status: 302 Found\nLocation: $url\n\n";
881}
882sub _act_u {
883 my $s = shift; $s = '' unless defined $s;
884 $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/eg;
885 return $s;
886}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help