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

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

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

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