Diff -- /var/www/vhosts/webstls.com/httpdocs/billing.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/billing.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:26:28

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