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

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

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

Added
+1028
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 382dbfe4d157
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 - 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#======================================================================
16use strict;
17use warnings;
18
19use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
20use CGI;
21use MODS::Template;
22use MODS::DBConnect;
23use MODS::Login;
24use MODS::RePricer::Config;
25use MODS::RePricer::Wrapper;
26use MODS::RePricer::Billing;
27use MODS::RePricer::Promotions;
28use MODS::RePricer::Stripe;
29
30my $q = CGI->new;
31my $form = $q->Vars;
32my $auth = MODS::Login->new;
33my $wrap = MODS::RePricer::Wrapper->new;
34my $tfile = MODS::Template->new;
35my $db = MODS::DBConnect->new;
36my $cfg = MODS::RePricer::Config->new;
37my $bill = MODS::RePricer::Billing->new;
38my $DB = $cfg->settings('database_name');
39
40$|=1;
41my $userinfo = $auth->login_verify();
42
43my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'billing';
44
45if ($entry eq 'billing_action') {
46 # Action handler mirrors billing.cgi's own auth gate: must be signed
47 # in, and must be owner or hold the manage_billing ptag.
48 if (!$userinfo) {
49 print "Status: 302 Found\nLocation: /login.cgi\n\n";
50 exit;
51 }
52 require MODS::RePricer::Permissions;
53 MODS::RePricer::Permissions->new->require_feature(
54 $userinfo, 'manage_billing', '/dashboard.cgi?denied=manage_billing'
55 );
56 _handle_action($q, $form);
57 exit;
58}
59
60if (!$userinfo) {
61 print "Status: 302 Found\nLocation: /login.cgi\n\n";
62 exit;
63}
64require MODS::RePricer::Permissions;
65# Billing is owner-by-default but can be delegated to a team seat via
66# the `manage_billing` ptag. Owners pass through has_feature's owner
67# short-circuit; team members without the grant bounce to /dashboard.cgi.
68MODS::RePricer::Permissions->new->require_feature(
69 $userinfo, 'manage_billing', '/dashboard.cgi?denied=manage_billing'
70);
71my $uid = $userinfo->{user_id};
72$uid =~ s/[^0-9]//g;
73
74# Tutorial mode: when ?tutorial=1 we paint an example subscription,
75# credit ledger and invoice history so a brand-new account can see
76# what their billing page reads like once they have history.
77my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
78
79my $dbh = $db->db_connect();
80my $schema_ready = $bill->schema_ready($db, $dbh, $DB);
81
82# Flip any deferred downgrades whose pending_change_at has passed.
83# Cheap: most page-loads see zero rows due. Until the dedicated billing
84# worker exists, this keeps a scheduled downgrade from sitting beyond
85# its anchor date.
86$bill->apply_pending_changes_if_due($db, $dbh, $DB, $uid) if $schema_ready;
87
88# ---- Subscription + credit balance ----------------------------------
89my $sub = $schema_ready ? $bill->active_subscription($db, $dbh, $DB, $uid) : {};
90my $balance = $schema_ready ? $bill->credit_balance_cents($db, $dbh, $DB, $uid) : 0;
91my @ledger = $schema_ready ? $bill->credit_history($db, $dbh, $DB, $uid, 10) : ();
92my @invoices_raw = $schema_ready ? $bill->list_invoices($db, $dbh, $DB, $uid, 24) : ();
93my @pms = $schema_ready ? $bill->list_payment_methods($db, $dbh, $DB, $uid) : ();
94my @all_plans = $schema_ready ? $bill->list_plans($db, $dbh, $DB) : ();
95
96# Group plans by tier so the picker shows one card per tier with both
97# cadence prices side-by-side.
98my %plans_by_tier;
99foreach my $p (@all_plans) {
100 push @{ $plans_by_tier{ $p->{tier} } }, $p;
101}
102
103# Helper: decode plan features JSON for the picker card body.
104sub _parse_features {
105 my $blob = shift;
106 return [] unless defined $blob && $blob ne '';
107 my @out;
108 # Quick & tolerant parser: features ship as
109 # [{"name":"X","included":true}, ...]
110 # Avoid pulling JSON.pm just for this; rows are short and trusted.
111 while ($blob =~ /\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"included"\s*:\s*(true|false)\s*\}/gs) {
112 push @out, { name => $1, included => ($2 eq 'true') ? 1 : 0 };
113 }
114 return \@out;
115}
116
117# Build the plan-tier cards for the template.
118# Resolve the user's "current" tier: only an ACTIVE subscription row
119# determines current tier. users.plan_tier can drift out of sync (admin
120# bootstrap may seed it to 'scale' without a real subscription), so we
121# only trust it when no subscription row exists AND it says 'pro' --
122# any other value with no sub row is inconsistent, treat as default Pro.
123my $sub_tier_raw = ($sub && ref $sub eq 'HASH') ? ($sub->{tier} || '') : '';
124my $sub_tier_active = ($sub_tier_raw &&
125 (($sub->{status} || '') =~ /^(active|trialing|past_due)$/)) ? $sub_tier_raw : '';
126my $current_tier = $sub_tier_active ? $sub_tier_active : 'pro';
127
128my @plan_cards;
129foreach my $tier (qw(pro business scale)) {
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 # billing_plans stores yearly_price_cents on the SAME row as monthly
136 # (yearly_mode='explicit' rows carry both). Separate annual rows
137 # aren't created by the seed, so prefer the monthly row's yearly
138 # column and fall back to a separate annual row only if one exists.
139 my $price_y_cents = $monthly && $monthly->{yearly_price_cents}
140 ? $monthly->{yearly_price_cents}
141 : ($annual ? $annual->{price_cents} : 0);
142 my $features = _parse_features($monthly ? $monthly->{features} : '');
143 my $is_current = ($current_tier eq $tier) ? 1 : 0;
144
145 # Quote the switch into THIS plan so the confirm modal can be
146 # specific about money: "Charged $17.00 now (prorated)..." rather
147 # than the generic "upgrades take effect now" copy. Skipped for
148 # the user's own current tier since there's no button anyway.
149 my ($confirm_title, $confirm_message, $confirm_style, $confirm_label);
150 if (!$is_current && $monthly && $monthly->{id}) {
151 my $q = $bill->quote_plan_change($db, $dbh, $DB, $uid, $monthly->{id}, 'monthly');
152 my $charge_today = $q->{charge_today_cents} || 0;
153 my $proration = $q->{proration_credit_cents} || 0;
154 my $renewal = $q->{renewal_amount_cents} || 0;
155 my $cls = $q->{classification} || 'immediate';
156
157 if ($tier eq 'free') {
158 # Cancel-to-free path. Always deferred to end of cycle.
159 $confirm_title = 'Cancel paid plan and move to Free?';
160 $confirm_message = 'You stay on your current plan until the end of the cycle, then drop to Free automatically. No further charges.';
161 $confirm_label = 'Move to Free';
162 $confirm_style = 'warning';
163 } elsif ($cls eq 'deferred') {
164 $confirm_title = 'Schedule downgrade to ' . ucfirst($tier) . '?';
165 $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.';
166 $confirm_label = 'Schedule downgrade';
167 $confirm_style = 'warning';
168 } else {
169 my $charge_str = $bill->fmt_money($charge_today);
170 my $renew_str = $bill->fmt_money($renewal);
171 $confirm_title = 'Switch to the ' . ucfirst($tier) . ' plan?';
172 if ($proration > 0) {
173 $confirm_message =
174 "You'll be charged $charge_str today. That's the $renew_str price minus a "
175 . $bill->fmt_money($proration)
176 . ' credit for the unused days of your current plan. Next renewal: ' . $renew_str . '/month.';
177 } elsif ($q->{needs_payment_method}) {
178 $confirm_message =
179 "You'll be charged $charge_str today, then $renew_str/month going forward. "
180 . 'You will be prompted to add a payment method first.';
181 } else {
182 $confirm_message =
183 "You'll be charged $charge_str today, then $renew_str/month going forward. "
184 . 'The charge runs against your card on file.';
185 }
186 $confirm_label = 'Switch to ' . ucfirst($tier) . ' · charge ' . $charge_str;
187 $confirm_style = 'primary';
188 }
189 } else {
190 $confirm_title = '';
191 $confirm_message = '';
192 $confirm_label = '';
193 $confirm_style = 'primary';
194 }
195
196 push @plan_cards, {
197 tier => $tier,
198 tier_label => ucfirst($tier),
199 display_name => $monthly ? $monthly->{display_name} : ucfirst($tier),
200 price_monthly => $bill->fmt_money($price_m_cents),
201 price_annual => $bill->fmt_money($price_y_cents),
202 is_free => ($tier eq 'free') ? 1 : 0,
203 price_m_cents => $price_m_cents,
204 price_y_cents => $price_y_cents,
205 plan_id_monthly => $monthly ? $monthly->{id} : 0,
206 plan_id_annual => $annual ? $annual->{id} : 0,
207 features => $features,
208 is_current => $is_current,
209 confirm_title => $confirm_title,
210 confirm_message => $confirm_message,
211 confirm_label => $confirm_label,
212 confirm_style => $confirm_style,
213 };
214}
215
216# Format invoice rows for the table.
217my @invoices;
218foreach my $r (@invoices_raw) {
219 my $status = $r->{status} || 'open';
220 push @invoices, {
221 id => $r->{id},
222 invoice_number => $r->{invoice_number} || ('INV-' . $r->{id}),
223 period_label => _format_period($r->{period_start}, $r->{period_end}),
224 amount_due_display => $bill->fmt_money($r->{amount_due_cents} || 0),
225 credit_applied_display => $bill->fmt_money($r->{credit_applied_cents} || 0),
226 cash_paid_display => $bill->fmt_money($r->{cash_paid_cents} || 0),
227 has_credit_applied => (($r->{credit_applied_cents} || 0) > 0) ? 1 : 0,
228 status => $status,
229 status_label => _status_label($status),
230 is_paid => $status eq 'paid' ? 1 : 0,
231 is_open => $status eq 'open' ? 1 : 0,
232 is_failed => $status eq 'failed' ? 1 : 0,
233 is_void => $status eq 'void' ? 1 : 0,
234 is_refunded => $status eq 'refunded' ? 1 : 0,
235 issued_at => $r->{issued_at} || $r->{created_at} || '',
236 paid_at => $r->{paid_at} || '-',
237 failure_reason => _h($r->{failure_reason} || ''),
238 has_failure => ($r->{failure_reason} && $status eq 'failed') ? 1 : 0,
239 };
240}
241
242# Credit-ledger rows for the activity feed.
243my @credit_rows;
244foreach my $r (@ledger) {
245 my $amt = $r->{amount_cents} || 0;
246 push @credit_rows, {
247 id => $r->{id},
248 amount_display => $bill->fmt_money($amt),
249 is_positive => ($amt >= 0) ? 1 : 0,
250 is_negative => ($amt < 0) ? 1 : 0,
251 reason => $r->{reason},
252 reason_label => _reason_label($r->{reason}),
253 note => _h($r->{note} || ''),
254 has_note => ($r->{note} && $r->{note} ne '') ? 1 : 0,
255 invoice_id => $r->{related_invoice_id} || 0,
256 created_at => $r->{created_at} || '',
257 };
258}
259
260# Default payment method.
261my %pm;
262foreach my $p (@pms) {
263 if ($p->{is_default} || !%pm) { %pm = %$p; }
264}
265my $has_pm = %pm ? 1 : 0;
266my $pm_brand_label = $has_pm ? (ucfirst($pm{brand} || 'card')) : '';
267my $pm_last4 = $has_pm ? ($pm{last4} || '----') : '';
268my $pm_exp = $has_pm ? sprintf('%02d/%02d', ($pm{exp_month} || 0), (($pm{exp_year} || 0) % 100)) : '';
269
270# Subscription display fields.
271my $has_sub = ($sub && $sub->{id}) ? 1 : 0;
272my $sub_status = $has_sub ? $sub->{status} : 'none';
273my $sub_plan_label = $has_sub ? $sub->{display_name} : 'Free';
274my $sub_tier = $has_sub ? $sub->{tier} : 'free';
275my $sub_cadence = $has_sub ? $sub->{cadence} : 'monthly';
276my $sub_price = $has_sub ? $bill->fmt_money($sub->{price_cents}) : '$0.00';
277my $sub_cadence_lbl = $bill->fmt_cadence($sub_cadence);
278my $sub_next_charge = $has_sub ? ($sub->{next_invoice_at} || 'not scheduled') : 'no upcoming charge';
279my $sub_is_canceling = ($has_sub && $sub->{cancel_at_period_end}) ? 1 : 0;
280my $sub_is_past_due = ($has_sub && $sub_status eq 'past_due') ? 1 : 0;
281my $sub_is_trialing = ($has_sub && $sub_status eq 'trialing') ? 1 : 0;
282
283# Pending plan change (scheduled downgrade). active_subscription() pulls
284# pending_plan_display_name / pending_plan_tier / pending_change_at when
285# the migration is in place; falsy when nothing is queued.
286my $has_pending_change = ($has_sub && $sub->{pending_plan_id}) ? 1 : 0;
287my $pending_plan_label = $has_pending_change ? ($sub->{pending_plan_display_name} || ucfirst($sub->{pending_plan_tier} || '')) : '';
288my $pending_change_at = $has_pending_change ? ($sub->{pending_change_at} || '') : '';
289my $pending_cadence_lbl = $has_pending_change ? ($bill->fmt_cadence($sub->{pending_cadence} || $sub->{cadence})) : '';
290
291# Estimate of the next-charge cash + credit split. Pure preview;
292# the worker recomputes against actual balance at invoice time.
293my $next_charge_cents = $has_sub ? ($sub->{price_cents} || 0) : 0;
294my $next_credit_applied = ($balance >= $next_charge_cents)
295 ? $next_charge_cents
296 : ($balance > 0 ? $balance : 0);
297my $next_cash_due = $next_charge_cents - $next_credit_applied;
298$next_cash_due = 0 if $next_cash_due < 0;
299
300$db->db_disconnect($dbh);
301
302# Active plan promo (for the marketing banner on the same panel; also
303# pre-fills the input so the seller can just click Apply).
304my $promo_obj = MODS::RePricer::Promotions->new;
305my $active_promo = $schema_ready ? $promo_obj->active_plan_promo($db, $dbh, $DB) : undef;
306my $promo_banner_code = ($active_promo && $active_promo->{code}) ? $active_promo->{code} : '';
307my $promo_banner_label = $active_promo ? $promo_obj->marketing_label($active_promo) : '';
308
309# Flash messages for the promo-code panel (set by billing_action.cgi
310# via ?promo_ok / ?promo_err query params on redirect).
311my $promo_flash_msg = '';
312my $promo_flash_kind = '';
313if (defined $form->{promo_ok} && length $form->{promo_ok}) {
314 my $cents = $form->{promo_ok}; $cents =~ s/[^0-9]//g; $cents = 0 unless $cents;
315 $promo_flash_kind = 'ok';
316 $promo_flash_msg = $cents
317 ? 'Code applied -- ' . $bill->fmt_money($cents) . ' in credit added. It will be used on your next invoice automatically.'
318 : 'Code accepted. Once you are on a paid plan, the discount will apply to your next invoice.';
319}
320if (defined $form->{promo_err} && length $form->{promo_err}) {
321 my $r = $form->{promo_err};
322 $r =~ s/[^A-Za-z0-9 .,;:_\-\$()%']//g;
323 $r = substr($r, 0, 200);
324 $promo_flash_kind = 'danger';
325 $promo_flash_msg = $r;
326}
327
328# Resume-upgrade hint: user just added a card after being bounced from
329# the change_plan flow. Quote the pending plan so we can render a one-
330# click "Complete upgrade" CTA at the top of the page.
331my $resume_plan = $form->{resume_plan} || '';
332$resume_plan =~ s/[^0-9]//g;
333my $resume_quote;
334my $resume_plan_label = '';
335my $resume_charge_str = '';
336if ($resume_plan) {
337 $resume_quote = $bill->quote_plan_change($db, $dbh, $DB, $uid, $resume_plan, '');
338 if ($resume_quote && ($resume_quote->{charge_today_cents} || 0) > 0) {
339 my $rp = $bill->plan_by_id($db, $dbh, $DB, $resume_plan);
340 $resume_plan_label = ($rp && $rp->{display_name}) ? $rp->{display_name} : 'paid';
341 $resume_charge_str = $bill->fmt_money($resume_quote->{charge_today_cents});
342 } else {
343 $resume_plan = '';
344 }
345}
346
347# Plan-change / charge flash. Same banner mechanism as the promo one,
348# just keyed off ?ok=plan_changed_charged or ?err=charge_failed.
349my $plan_flash_msg = '';
350my $plan_flash_kind = '';
351my $okv = defined $form->{ok} ? $form->{ok} : '';
352my $errv = defined $form->{err} ? $form->{err} : '';
353if ($okv eq 'plan_changed_charged') {
354 my $amt = $form->{amount}; $amt =~ s/[^0-9]//g if defined $amt; $amt = 0 unless $amt;
355 $plan_flash_kind = 'ok';
356 $plan_flash_msg = $amt
357 ? ('Plan upgraded and ' . $bill->fmt_money($amt) . ' charged to your card. Your new features are active right now.')
358 : 'Plan upgraded. Your new features are active right now.';
359} elsif ($okv eq 'plan_changed') {
360 $plan_flash_kind = 'ok';
361 $plan_flash_msg = 'Plan updated.';
362} elsif ($okv eq 'plan_scheduled') {
363 my $when = $form->{when}; $when =~ s/[^0-9\-: ]//g if defined $when;
364 $plan_flash_kind = 'ok';
365 $plan_flash_msg = 'Downgrade scheduled' . ($when ? " for $when." : '. It takes effect at the end of your current cycle.');
366} elsif ($errv eq 'charge_failed') {
367 my $detail = $form->{detail} || '';
368 $detail =~ s/[^A-Za-z0-9 .,;:_\-\$()%'#]//g;
369 $detail = substr($detail, 0, 240);
370 $plan_flash_kind = 'danger';
371 $plan_flash_msg = $detail
372 ? "Charge failed: $detail Your plan was NOT changed. Try a different card or contact support."
373 : 'Charge failed. Your plan was NOT changed. Try a different card or contact support.';
374} elsif ($errv eq 'plan_change_failed') {
375 $plan_flash_kind = 'danger';
376 $plan_flash_msg = 'Could not change your plan. Please try again or contact support.';
377}
378
379my $tvars = {
380 user_id => $uid,
381 promo_flash_msg => $promo_flash_msg,
382 promo_flash_kind => $promo_flash_kind,
383 has_promo_flash => length $promo_flash_msg ? 1 : 0,
384 plan_flash_msg => $plan_flash_msg,
385 plan_flash_kind => $plan_flash_kind,
386 plan_flash_is_ok => ($plan_flash_kind eq 'ok') ? 1 : 0,
387 plan_flash_is_err => ($plan_flash_kind eq 'danger') ? 1 : 0,
388 has_plan_flash => length $plan_flash_msg ? 1 : 0,
389 has_resume_plan => $resume_plan ? 1 : 0,
390 resume_plan_id => $resume_plan,
391 resume_plan_label => $resume_plan_label,
392 resume_charge_str => $resume_charge_str,
393 has_active_promo => $active_promo ? 1 : 0,
394 active_promo_code => $promo_banner_code,
395 active_promo_label => $promo_banner_label,
396
397 # Subscription
398 has_sub => $has_sub,
399 sub_status => $sub_status,
400 sub_status_label => _status_label($sub_status),
401 sub_plan_label => $sub_plan_label,
402 sub_tier => $sub_tier,
403 sub_tier_label => ucfirst($sub_tier || 'free'),
404 sub_cadence => $sub_cadence,
405 sub_cadence_label => $sub_cadence_lbl,
406 sub_price => $sub_price,
407 sub_next_charge => $sub_next_charge,
408 sub_is_canceling => $sub_is_canceling,
409 sub_is_past_due => $sub_is_past_due,
410 sub_is_trialing => $sub_is_trialing,
411
412 # Pending downgrade banner
413 has_pending_change => $has_pending_change,
414 pending_plan_label => $pending_plan_label,
415 pending_change_at => $pending_change_at,
416 pending_cadence_label => $pending_cadence_lbl,
417
418 next_charge_display => $bill->fmt_money($next_charge_cents),
419 next_credit_display => $bill->fmt_money($next_credit_applied),
420 next_cash_display => $bill->fmt_money($next_cash_due),
421 has_next_credit_applied => ($next_credit_applied > 0) ? 1 : 0,
422 next_cash_due_zero => ($next_cash_due == 0 && $next_charge_cents > 0) ? 1 : 0,
423
424 # Credit
425 credit_balance_display => $bill->fmt_money($balance),
426 credit_balance_cents => $balance,
427 has_credit_balance => ($balance > 0) ? 1 : 0,
428 credit_rows => \@credit_rows,
429 has_credit_history => scalar(@credit_rows) ? 1 : 0,
430
431 # Plans
432 plan_cards => \@plan_cards,
433 has_plans => scalar(@plan_cards) ? 1 : 0,
434
435 # Invoices
436 invoices => \@invoices,
437 has_invoices => scalar(@invoices) ? 1 : 0,
438
439 # Payment method
440 has_pm => $has_pm,
441 pm_brand_label => $pm_brand_label,
442 pm_last4 => $pm_last4,
443 pm_exp => $pm_exp,
444
445 # Schema readiness banner
446 schema_ready => $schema_ready ? 1 : 0,
447 schema_missing => $schema_ready ? 0 : 1,
448
449 # Tutorial mode
450 tutorial_mode => $tutorial_mode,
451 not_tutorial_mode => $tutorial_mode ? 0 : 1,
452};
453
454# ---- Tutorial-mode overlay -----------------------------------------
455# Painted only when ?tutorial=1. No DB; illustrative.
456if ($tutorial_mode) {
457 $tvars->{has_sub} = 1;
458 $tvars->{sub_status} = 'active';
459 $tvars->{sub_status_label} = 'Active';
460 $tvars->{sub_plan_label} = 'Pro';
461 $tvars->{sub_tier} = 'pro';
462 $tvars->{sub_tier_label} = 'Pro';
463 $tvars->{sub_cadence} = 'monthly';
464 $tvars->{sub_cadence_label} = 'month';
465 $tvars->{sub_price} = '$49.00';
466 $tvars->{sub_next_charge} = '2026-06-12';
467 $tvars->{sub_is_canceling} = 0;
468 $tvars->{sub_is_past_due} = 0;
469 $tvars->{sub_is_trialing} = 0;
470
471 $tvars->{next_charge_display} = '$49.00';
472 $tvars->{next_credit_display} = '$12.00';
473 $tvars->{next_cash_display} = '$37.00';
474 $tvars->{has_next_credit_applied} = 1;
475 $tvars->{next_cash_due_zero} = 0;
476
477 $tvars->{credit_balance_display} = '$12.00';
478 $tvars->{credit_balance_cents} = 1200;
479 $tvars->{has_credit_balance} = 1;
480 $tvars->{credit_rows} = [
481 { id=>3, amount_display=>'$25.00', is_positive=>1, is_negative=>0,
482 reason=>'refund', reason_label=>'Refund as credit',
483 note=>'Refund for April invoice issue (admin-issued).', has_note=>1,
484 invoice_id=>0, created_at=>'2026-05-02' },
485 { id=>2, amount_display=>'-$13.00', is_positive=>0, is_negative=>1,
486 reason=>'applied_to_invoice', reason_label=>'Applied to invoice INV-0001247-202605',
487 note=>'', has_note=>0,
488 invoice_id=>0, created_at=>'2026-05-12' },
489 { id=>1, amount_display=>'$10.00', is_positive=>1, is_negative=>0,
490 reason=>'promotional', reason_label=>'Promotional credit',
491 note=>'Welcome bonus for upgrading to Pro.', has_note=>1,
492 invoice_id=>0, created_at=>'2026-03-01' },
493 ];
494 $tvars->{has_credit_history} = 1;
495
496 # Pretend plans for the picker. Pro = current.
497 $tvars->{plan_cards} = [
498 { tier=>'pro', tier_label=>'Pro', display_name=>'Pro',
499 price_monthly=>'$49.00', price_annual=>'$490.00',
500 is_free=>0, price_m_cents=>4900, price_y_cents=>49000,
501 plan_id_monthly=>1, plan_id_annual=>2,
502 features=>[
503 { name=>'Up to 1,000 SKUs', included=>1 },
504 { name=>'Reprice every 5 minutes', included=>1 },
505 { name=>'Amazon marketplace', included=>1 },
506 { name=>'All 8 strategies + MAP guardrails', included=>1 },
507 { name=>'Walmart marketplace', included=>0 },
508 { name=>'eBay marketplace', included=>0 },
509 ],
510 is_current=>1 },
511 { tier=>'business', tier_label=>'Business', display_name=>'Business',
512 price_monthly=>'$149.00', price_annual=>'$1,490.00',
513 is_free=>0, price_m_cents=>14900, price_y_cents=>149000,
514 plan_id_monthly=>3, plan_id_annual=>4,
515 features=>[
516 { name=>'Everything in Pro', included=>1 },
517 { name=>'Up to 10,000 SKUs', included=>1 },
518 { name=>'Reprice every minute', included=>1 },
519 { name=>'Amazon + Walmart', included=>1 },
520 { name=>'Priority support', included=>1 },
521 { name=>'eBay marketplace', included=>0 },
522 ],
523 is_current=>0 },
524 { tier=>'scale', tier_label=>'Scale', display_name=>'Scale',
525 price_monthly=>'$349.00', price_annual=>'$3,490.00',
526 is_free=>0, price_m_cents=>34900, price_y_cents=>349000,
527 plan_id_monthly=>5, plan_id_annual=>6,
528 features=>[
529 { name=>'Everything in Business', included=>1 },
530 { name=>'Unlimited SKUs', included=>1 },
531 { name=>'Amazon + Walmart + eBay', included=>1 },
532 { name=>'Custom strategies', included=>1 },
533 { name=>'99.95% uptime SLA', included=>1 },
534 { name=>'Dedicated CSM + SAML SSO', included=>1 },
535 ],
536 is_current=>0 },
537 ];
538 $tvars->{has_plans} = 1;
539
540 $tvars->{invoices} = [
541 { id=>4, invoice_number=>'INV-0001247-202605',
542 period_label=>'May 12 - Jun 11, 2026',
543 amount_due_display=>'$29.00', credit_applied_display=>'$12.00',
544 cash_paid_display=>'$17.00', has_credit_applied=>1,
545 status=>'paid', status_label=>'Paid',
546 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
547 issued_at=>'2026-05-12', paid_at=>'2026-05-12',
548 failure_reason=>'', has_failure=>0 },
549 { id=>3, invoice_number=>'INV-0001247-202604',
550 period_label=>'Apr 12 - May 11, 2026',
551 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
552 cash_paid_display=>'$29.00', has_credit_applied=>0,
553 status=>'refunded', status_label=>'Refunded as credit',
554 is_paid=>0, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>1,
555 issued_at=>'2026-04-12', paid_at=>'2026-04-12',
556 failure_reason=>'', has_failure=>0 },
557 { id=>2, invoice_number=>'INV-0001247-202603',
558 period_label=>'Mar 12 - Apr 11, 2026',
559 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
560 cash_paid_display=>'$29.00', has_credit_applied=>0,
561 status=>'paid', status_label=>'Paid',
562 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
563 issued_at=>'2026-03-12', paid_at=>'2026-03-12',
564 failure_reason=>'', has_failure=>0 },
565 { id=>1, invoice_number=>'INV-0001247-202602',
566 period_label=>'Feb 12 - Mar 11, 2026',
567 amount_due_display=>'$29.00', credit_applied_display=>'$0.00',
568 cash_paid_display=>'$29.00', has_credit_applied=>0,
569 status=>'paid', status_label=>'Paid',
570 is_paid=>1, is_open=>0, is_failed=>0, is_void=>0, is_refunded=>0,
571 issued_at=>'2026-02-12', paid_at=>'2026-02-12',
572 failure_reason=>'', has_failure=>0 },
573 ];
574 $tvars->{has_invoices} = 1;
575
576 $tvars->{has_pm} = 1;
577 $tvars->{pm_brand_label} = 'Visa';
578 $tvars->{pm_last4} = '4242';
579 $tvars->{pm_exp} = '08/29';
580
581 $tvars->{schema_ready} = 1;
582 $tvars->{schema_missing} = 0;
583}
584
585# --- Inline HTML render -------------------------------------------------
586# The WebSTLs-inherited template engine hangs silently mid-parse on
587# repricer_billing.html (template opens fine; parsing dies before
588# run_in_memory returns -- not catchable by eval, suggests a regex /
589# loop pathology in the parser, not a template error). Bypass it: build
590# the HTML inline using the same data we already loaded. Much simpler,
591# fewer surprises, and the Wrapper render still gives us the chrome.
592
593sub _esc { my $s = shift // ''; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; $s }
594
595my $body = '<div style="max-width:980px;margin:0 auto;padding:24px 28px">';
596$body .= ' <div style="margin-bottom:24px">';
597$body .= ' <div style="font-size:11px;letter-spacing:2px;color:#14b8a6;text-transform:uppercase">Billing</div>';
598$body .= ' <h1 style="font-size:26px;color:#fff;margin:6px 0 4px;font-weight:700">Subscription &amp; invoices</h1>';
599$body .= ' <p style="color:#7e92b6;font-size:14px;margin:0">Manage your RePricer plan, payment method, and invoice history.</p>';
600$body .= ' </div>';
601
602# Schema banner
603unless ($schema_ready) {
604 $body .= '<div style="background:#7c2d12;border:1px solid #ea580c;color:#fdba74;padding:12px 18px;border-radius:8px;margin-bottom:18px;font-size:14px">';
605 $body .= ' <b>Subscription tables not fully migrated.</b> The credit_ledger table (and possibly others) is missing. Re-run the subscriptions section of <code>html/db_schema.sql</code> then refresh.';
606 $body .= '</div>';
607}
608
609# Current subscription / next charge panel
610my $sub_status = ref $sub eq 'HASH' ? ($sub->{status} || '') : '';
611my $has_sub = ($sub_status && $sub_status =~ /^(active|trialing|past_due)$/) ? 1 : 0;
612$body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
613$body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:10px">Current plan</div>';
614if ($has_sub) {
615 my $plan_label = _esc(ucfirst($sub->{tier} || 'free'));
616 my $price_cents = $sub->{cadence} && $sub->{cadence} eq 'annual'
617 ? ($sub->{yearly_price_cents} // 0)
618 : ($sub->{price_cents} // 0);
619 my $price = $bill->fmt_money($price_cents);
620 my $next = _esc($sub->{current_period_end} || $sub->{next_invoice_at} || 'not scheduled');
621 my $cadence = _esc($sub->{cadence} // 'monthly');
622 $body .= " <div style=\"display:flex;align-items:baseline;gap:12px;flex-wrap:wrap\">";
623 $body .= " <div style=\"font-size:24px;font-weight:700;color:#fff\">$plan_label</div>";
624 $body .= " <div style=\"color:#14b8a6;font-size:16px\">$price / $cadence</div>";
625 $body .= " <div style=\"color:#7e92b6;font-size:13px;margin-left:auto\">Status: <b style=\"color:#10b981\">" . _esc($sub_status) . "</b></div>";
626 $body .= " </div>";
627 $body .= " <div style=\"color:#7e92b6;font-size:13px;margin-top:8px\">Next invoice: <b style=\"color:#e6ecf6\">$next</b></div>";
628 $body .= ' <div style="margin-top:14px;padding-top:14px;border-top:1px dashed #1f2a4a"><a href="/retention.cgi" style="color:#7e92b6;font-size:12px;text-decoration:none">Cancel my plan &rarr;</a></div>';
629} else {
630 $body .= ' <div style="color:#e6ecf6;font-size:16px">No active subscription &mdash; pick a plan below to start your <b>14-day free trial</b>.</div>';
631 $body .= ' <div style="color:#7e92b6;font-size:13px;margin-top:6px">During the trial you can manage up to 10 SKUs on any plan. No card required to start; add a card before day 14 to keep going.</div>';
632}
633$body .= '</section>';
634
635# Credit balance (if any)
636if ($balance > 0) {
637 $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
638 $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:6px">Account credit</div>';
639 $body .= ' <div style="font-size:22px;color:#10b981;font-weight:700">' . $bill->fmt_money($balance) . '</div>';
640 $body .= ' <div style="color:#7e92b6;font-size:12px;margin-top:4px">Applied automatically to your next invoice before any card charge.</div>';
641 $body .= '</section>';
642}
643
644# Plan picker (always shown so the user can upgrade)
645$body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
646$body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:14px">Available plans</div>';
647if (@plan_cards) {
648 $body .= ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px">';
649 foreach my $p (@plan_cards) {
650 my $tier = _esc($p->{tier});
651 my $label = _esc($p->{display_name});
652 my $pm = _esc($p->{price_monthly});
653 my $py = _esc($p->{price_annual});
654 my $cur = $p->{is_current} ? 1 : 0;
655 my $ring = $cur ? 'border:2px solid #14b8a6' : 'border:1px solid #1f2a4a';
656 my $btn_html;
657 if ($cur) {
658 $btn_html = '<button disabled style="width:100%;padding:8px;background:#1f2a4a;color:#7e92b6;border:0;border-radius:6px;font-weight:600;cursor:not-allowed">Current plan</button>';
659 } elsif ($p->{plan_id_monthly}) {
660 my $pid = $p->{plan_id_monthly};
661 # billing_action.cgi reads $form->{act} (plain, not _act)
662 $btn_html = qq~<form method="POST" action="/billing_action.cgi" style="margin:0"><input type="hidden" name="act" value="change_plan"><input type="hidden" name="plan_id" value="$pid"><input type="hidden" name="cadence" value="monthly"><button type="submit" style="width:100%;padding:8px;background:#064e3b;color:#a7f3d0;border:0;border-radius:6px;font-weight:600;cursor:pointer">Choose $label</button></form>~;
663 } else {
664 $btn_html = '<div style="font-size:11px;color:#7e92b6;text-align:center;padding:8px">Plan not configured</div>';
665 }
666 $body .= qq~
667 <div style="background:#0a0f1f;$ring;border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:6px">
668 <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase">$tier</div>
669 <div style="font-size:18px;color:#fff;font-weight:700">$label</div>
670 <div style="font-size:13px;color:#14b8a6">$pm/mo &middot; <span style="color:#7e92b6">$py/yr</span></div>
671 <div style="margin-top:8px">$btn_html</div>
672 </div>~;
673 }
674 $body .= ' </div>';
675 $body .= ' <div style="color:#7e92b6;font-size:12px;margin-top:12px">Annual billing locks in roughly 2 months free vs. monthly. Switching between paid tiers prorates instantly; downgrading to Free is deferred to the end of your cycle.</div>';
676} else {
677 $body .= ' <div style="color:#fca5a5">No plans configured. Run the billing_plans seed in db_schema.sql.</div>';
678}
679$body .= '</section>';
680
681# Payment method
682$body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
683$body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:8px">Payment method</div>';
684if (@pms) {
685 my $pm = $pms[0];
686 my $brand = _esc($pm->{brand} || $pm->{card_brand} || 'Card');
687 my $last4 = _esc($pm->{last4} || '****');
688 my $exp = _esc($pm->{exp_month} && $pm->{exp_year} ? sprintf('%02d/%s', $pm->{exp_month}, substr($pm->{exp_year}, -2)) : '--');
689 $body .= qq~ <div style="color:#e6ecf6;font-size:14px">$brand ending in <b>$last4</b> &middot; expires $exp</div>~;
690 $body .= ' <a href="/billing_payment.cgi" style="color:#14b8a6;font-size:13px;display:inline-block;margin-top:6px">Update card &rarr;</a>';
691} else {
692 $body .= ' <div style="color:#7e92b6;font-size:14px">No payment method on file.</div>';
693 $body .= ' <a href="/billing_payment.cgi" style="display:inline-block;margin-top:8px;padding:8px 14px;background:#064e3b;color:#a7f3d0;border-radius:6px;font-weight:600;text-decoration:none;font-size:13px">Add payment method</a>';
694}
695$body .= '</section>';
696
697# Recent invoices
698if (@invoices) {
699 $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
700 $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:10px">Recent invoices</div>';
701 $body .= ' <table style="width:100%;border-collapse:collapse;font-size:13px;color:#e6ecf6"><thead><tr style="color:#7e92b6;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:1px"><th style="padding:8px 6px;border-bottom:1px solid #1f2a4a">Invoice</th><th style="padding:8px 6px;border-bottom:1px solid #1f2a4a">Period</th><th style="padding:8px 6px;border-bottom:1px solid #1f2a4a">Amount</th><th style="padding:8px 6px;border-bottom:1px solid #1f2a4a">Status</th></tr></thead><tbody>';
702 foreach my $inv (@invoices) {
703 my $sclass = $inv->{is_paid} ? '#10b981' : $inv->{is_failed} ? '#fca5a5' : '#fbbf24';
704 $body .= sprintf(
705 '<tr><td style="padding:8px 6px;border-bottom:1px solid #1f2a4a">%s</td><td style="padding:8px 6px;border-bottom:1px solid #1f2a4a;color:#7e92b6">%s</td><td style="padding:8px 6px;border-bottom:1px solid #1f2a4a">%s</td><td style="padding:8px 6px;border-bottom:1px solid #1f2a4a;color:%s">%s</td></tr>',
706 _esc($inv->{invoice_number}), _esc($inv->{period_label}),
707 _esc($inv->{amount_due_display}), $sclass, _esc($inv->{status_label}),
708 );
709 }
710 $body .= '</tbody></table></section>';
711}
712
713# Credit ledger
714if (@credit_rows) {
715 $body .= '<section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 22px;margin-bottom:16px">';
716 $body .= ' <div style="font-size:11px;letter-spacing:1.5px;color:#7e92b6;text-transform:uppercase;font-weight:600;margin-bottom:10px">Credit history</div>';
717 foreach my $c (@credit_rows) {
718 my $amt = _esc($c->{amount_display});
719 my $col = $c->{is_positive} ? '#10b981' : '#fca5a5';
720 $body .= qq~ <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px dashed #1f2a4a;font-size:13px"><span style="color:#e6ecf6">~ . _esc($c->{reason_label}) . qq~</span><span style="color:$col;font-weight:600">$amt</span></div>~;
721 }
722 $body .= '</section>';
723}
724
725$body .= '</div>';
726
727print "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";
728$wrap->render({
729 userinfo => $userinfo,
730 page_key => 'billing',
731 title => 'Billing',
732 body => $body,
733});
734
735# ----------------------------------------------------------------- helpers
736sub _format_period {
737 my ($s, $e) = @_;
738 return '-' unless ($s || $e);
739 return ($s || '') . ' - ' . ($e || '');
740}
741
742sub _status_label {
743 my $s = shift || '';
744 return 'Active' if $s eq 'active';
745 return 'Trialing' if $s eq 'trialing';
746 return 'Past due' if $s eq 'past_due';
747 return 'Canceled' if $s eq 'canceled';
748 return 'Paused' if $s eq 'paused';
749 return 'Paid' if $s eq 'paid';
750 return 'Open' if $s eq 'open';
751 return 'Failed' if $s eq 'failed';
752 return 'Void' if $s eq 'void';
753 return 'Refunded as credit' if $s eq 'refunded';
754 return ucfirst($s);
755}
756
757sub _reason_label {
758 my $r = shift || '';
759 return 'Refund as credit' if $r eq 'refund';
760 return 'Manual credit grant' if $r eq 'manual_grant';
761 return 'Promotional credit' if $r eq 'promotional';
762 return 'Applied to invoice' if $r eq 'applied_to_invoice';
763 return 'Reversal' if $r eq 'reversal';
764 return 'Expired' if $r eq 'expired';
765 return ucfirst($r);
766}
767
768sub _h {
769 my $s = shift;
770 $s //= '';
771 $s =~ s/&/&amp;/g;
772 $s =~ s/</&lt;/g;
773 $s =~ s/>/&gt;/g;
774 $s =~ s/"/&quot;/g;
775 return $s;
776}
777
778#======================================================================
779# billing_action entry: POST-only handler for the buttons on
780# /billing.cgi:
781# act=change_plan - upgrade (immediate) or downgrade
782# (deferred to current cycle end)
783# via MODS::RePricer::Billing::change_plan
784# act=clear_pending_change - cancel a queued deferred downgrade
785# act=cancel_subscription - mark cancel_at_period_end=1
786# act=resume_subscription - undo a pending cancellation
787# act=apply_promo_code - redeem a plan-scope promo code
788# act=update_payment_method - legacy alias, bounces to /billing_payment.cgi
789# act=add_payment_method - legacy alias, bounces to /billing_payment.cgi
790# Every action redirects back to /billing.cgi (or /billing_payment.cgi)
791# with a status flash.
792#======================================================================
793sub _handle_action {
794 my ($q, $form) = @_;
795
796 my $uid = $userinfo->{user_id};
797 $uid =~ s/[^0-9]//g;
798
799 my $act = defined $form->{act} ? $form->{act} : '';
800
801 my $dbh = $db->db_connect();
802 unless ($bill->schema_ready($db, $dbh, $DB)) {
803 $db->db_disconnect($dbh);
804 _redirect('/billing.cgi?err=schema');
805 return;
806 }
807
808 if ($act eq 'apply_promo_code') {
809 # User typed a code into the "Have a promo code?" panel on
810 # /billing.cgi. We validate against the platform promotions table,
811 # compute the discount against the user's CURRENT subscription
812 # price (the cleanest reference -- if they're on Free we still
813 # allow the apply but the discount lands at $0 with a hint to
814 # upgrade), then grant the equivalent as promotional credit
815 # via the existing credit_ledger flow. The credit auto-applies on
816 # the next invoice -- no second handoff needed.
817 my $code = defined $form->{promo_code} ? $form->{promo_code} : '';
818 my $promo = MODS::RePricer::Promotions->new;
819
820 my $row = $promo->load_by_code($db, $dbh, $DB, $code, kind => 'plan');
821 unless ($row && $row->{id}) {
822 $db->db_disconnect($dbh);
823 _redirect('/billing.cgi?promo_err=' . _u('That code is not valid for plan upgrades.'));
824 return;
825 }
826
827 # Reference price for the discount math. Falls back to 0 on Free.
828 # We require an active paid subscription before applying -- otherwise
829 # the discount would be $0 and the user would be confused.
830 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
831 my $ref_cents = ($sub && $sub->{price_cents}) ? $sub->{price_cents} : 0;
832 unless ($ref_cents > 0) {
833 $db->db_disconnect($dbh);
834 _redirect('/billing.cgi?promo_err=' . _u('Upgrade to a paid plan first, then apply your promo code.'));
835 return;
836 }
837
838 # Per-plan eligibility check. A promo with zero rows in
839 # promotion_plans applies to every paid plan; one with explicit
840 # rows must include the user's current plan.
841 my $plan_now = ($sub && $sub->{plan_id})
842 ? $bill->plan_by_id($db, $dbh, $DB, $sub->{plan_id})
843 : undef;
844 unless ($plan_now && $promo->promo_applies_to_plan($db, $dbh, $DB, $row->{id}, $plan_now)) {
845 $db->db_disconnect($dbh);
846 _redirect('/billing.cgi?promo_err=' . _u("That code isn't valid on your current plan. Switch to a covered plan first, then re-apply."));
847 return;
848 }
849
850 my %v = $promo->validate($db, $dbh, $DB, $row,
851 kind => 'plan',
852 user_id => $uid,
853 subtotal_cents => $ref_cents,
854 new_customer => 1, # plan promos don't gate by orders
855 );
856 unless ($v{ok}) {
857 $db->db_disconnect($dbh);
858 _redirect('/billing.cgi?promo_err=' . _u($v{reason} || 'That code is not valid right now.'));
859 return;
860 }
861
862 my $disc = $promo->apply_to_total_cents($row, $ref_cents);
863 my $rid = $promo->redeem($db, $dbh, $DB,
864 promotion_id => $row->{id},
865 target_kind => 'plan',
866 user_id => $uid,
867 subscription_id => ($sub && $sub->{id}) ? $sub->{id} : undef,
868 invoice_id => undef, # no invoice generated yet
869 discount_applied_cents => $disc,
870 );
871 unless ($rid) {
872 $db->db_disconnect($dbh);
873 _redirect('/billing.cgi?promo_err=' . _u('Could not redeem the code. Please try again.'));
874 return;
875 }
876
877 # Convert the discount into promotional credit so the existing
878 # credit-applies-to-next-invoice machinery handles the rest.
879 if ($disc > 0) {
880 my $note = 'Promotion ' . ($row->{code} || ('#' . $row->{id}));
881 $bill->grant_credit($db, $dbh, $DB,
882 user_id => $uid,
883 amount_cents => $disc,
884 reason => 'promotional',
885 note => $note,
886 related_invoice_id => undef,
887 granted_by_user_id => undef,
888 );
889 }
890
891 $db->db_disconnect($dbh);
892 _redirect('/billing.cgi?promo_ok=' . $disc);
893 return;
894 }
895
896 if ($act eq 'change_plan') {
897 my $plan_id = defined $form->{plan_id} ? $form->{plan_id} : '';
898 $plan_id =~ s/[^0-9]//g;
899 unless ($plan_id) {
900 $db->db_disconnect($dbh);
901 _redirect('/billing.cgi?err=bad_plan');
902 return;
903 }
904
905 # Caller may optionally pass a cadence override; otherwise the
906 # helper inherits the current subscription's cadence.
907 my $cadence = defined $form->{cadence} ? $form->{cadence} : '';
908 $cadence = 'annual' if $cadence eq 'annual';
909 $cadence = '' unless $cadence eq 'annual' || $cadence eq 'monthly';
910
911 # Quote the change so we can branch on "charge required" vs
912 # "free downgrade / deferred". Upgrades with money owed go
913 # through charge-first flow; everything else takes the legacy
914 # change_plan() path (no money to move).
915 my $quote = $bill->quote_plan_change($db, $dbh, $DB, $uid, $plan_id, $cadence);
916
917 if (($quote->{charge_today_cents} || 0) > 0) {
918 # Real-money upgrade. Must have a payment method on file --
919 # otherwise bounce to /billing_payment.cgi so they add one
920 # then come back and confirm.
921 if ($quote->{needs_payment_method}) {
922 $db->db_disconnect($dbh);
923 _redirect('/billing_payment.cgi?need_card=1&next_plan=' . $plan_id);
924 return;
925 }
926 my $stripe = MODS::RePricer::Stripe->new;
927 my $result = $bill->change_plan_with_charge(
928 $db, $dbh, $DB, $uid, $plan_id, $cadence, $stripe
929 );
930 $db->db_disconnect($dbh);
931 if (!$result || !$result->{ok}) {
932 my $reason = ($result && $result->{reason}) ? $result->{reason} : 'unknown';
933 my $msg = ($result && $result->{error}) ? $result->{error} : '';
934 if ($reason eq 'no_pm') {
935 _redirect('/billing_payment.cgi?need_card=1&next_plan=' . $plan_id);
936 } else {
937 _redirect('/billing.cgi?err=charge_failed&reason=' . _u($reason)
938 . ($msg ? '&detail=' . _u($msg) : ''));
939 }
940 return;
941 }
942 _redirect('/billing.cgi?ok=plan_changed_charged&amount=' . ($result->{amount_cents} || 0));
943 return;
944 }
945
946 # Zero-cost path: noop, free downgrade, or deferred downgrade.
947 # Hand off to the legacy change_plan() which already handles
948 # those cases.
949 my $result = $bill->change_plan($db, $dbh, $DB, $uid, $plan_id, $cadence);
950 $db->db_disconnect($dbh);
951
952 if (!$result || !$result->{mode}) {
953 _redirect('/billing.cgi?err=plan_change_failed');
954 return;
955 }
956 if ($result->{mode} eq 'immediate') {
957 _redirect('/billing.cgi?ok=plan_changed');
958 return;
959 }
960 if ($result->{mode} eq 'deferred') {
961 my $when = defined $result->{effective_at} ? $result->{effective_at} : '';
962 _redirect('/billing.cgi?ok=plan_scheduled&when=' . _u($when));
963 return;
964 }
965 # mode=noop -- same plan re-selected. Quiet redirect.
966 _redirect('/billing.cgi');
967 return;
968 }
969
970 if ($act eq 'clear_pending_change') {
971 $bill->clear_pending_change($db, $dbh, $DB, $uid);
972 $db->db_disconnect($dbh);
973 _redirect('/billing.cgi?ok=pending_cleared');
974 return;
975 }
976
977 if ($act eq 'cancel_subscription') {
978 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
979 if ($sub && $sub->{id}) {
980 $db->db_readwrite($dbh, qq~
981 UPDATE `${DB}`.subscriptions
982 SET cancel_at_period_end=1
983 WHERE id='$sub->{id}' AND user_id='$uid'
984 ~, $ENV{SCRIPT_NAME}, __LINE__);
985 }
986 $db->db_disconnect($dbh);
987 _redirect('/billing.cgi?ok=canceled');
988 return;
989 }
990
991 if ($act eq 'resume_subscription') {
992 my $sub = $bill->active_subscription($db, $dbh, $DB, $uid);
993 if ($sub && $sub->{id}) {
994 $db->db_readwrite($dbh, qq~
995 UPDATE `${DB}`.subscriptions
996 SET cancel_at_period_end=0,
997 status='active'
998 WHERE id='$sub->{id}' AND user_id='$uid'
999 ~, $ENV{SCRIPT_NAME}, __LINE__);
1000 }
1001 $db->db_disconnect($dbh);
1002 _redirect('/billing.cgi?ok=resumed');
1003 return;
1004 }
1005
1006 if ($act eq 'update_payment_method' || $act eq 'add_payment_method') {
1007 # Card management now lives on /billing_payment.cgi (Stripe Elements
1008 # SetupIntent flow). Old buttons cached in browser tabs end up here;
1009 # bounce them to the real page.
1010 $db->db_disconnect($dbh);
1011 _redirect('/billing_payment.cgi');
1012 return;
1013 }
1014
1015 $db->db_disconnect($dbh);
1016 _redirect('/billing.cgi?err=unknown_act');
1017 return;
1018}
1019
1020sub _redirect {
1021 my $url = shift;
1022 print "Status: 302 Found\nLocation: $url\n\n";
1023}
1024sub _u {
1025 my $s = shift; $s = '' unless defined $s;
1026 $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/eg;
1027 return $s;
1028}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help