Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/_billing_worker.pl
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/_billing_worker.pl

added on local at 2026-07-01 15:03:22

Added
+126
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 6d30c7c3f732
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 - Recurring billing worker.
4#
5# Cron-driven. Picks subscriptions whose next_invoice_at has arrived
6# and runs them through the invoice lifecycle:
7#
8# 1. apply any deferred plan change that's now due
9# 2. generate the invoice (idempotent)
10# 3. apply credit balance to the invoice
11# 4. charge the rep's default card if cash remains
12# 5. on success: status='paid', advance next_invoice_at
13# on failure: status='past_due', set last_charge_error
14#
15# Idempotent end-to-end. If the cron runs twice, the second pass
16# finds 'paid' invoices and skips them. If Stripe returns success but
17# we crash before updating the row, the webhook (payment_intent.succeeded)
18# closes the loop. The Stripe idempotency_key is keyed on invoice_id so
19# the API itself never double-charges.
20#
21# Invocation:
22# /usr/bin/perl /var/www/vhosts/3dshawn.com/crm.3dshawn.com/_billing_worker.pl
23# Recommended cron (Plesk Scheduled Tasks):
24# */15 * * * * -- every 15 minutes
25#======================================================================
26use strict;
27use warnings;
28
29use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
30use MODS::DBConnect;
31use MODS::ContactForge::Config;
32use MODS::ContactForge::Billing;
33use MODS::ContactForge::Stripe;
34
35$|=1;
36
37my $db = MODS::DBConnect->new;
38my $cfg = MODS::ContactForge::Config->new;
39my $bill = MODS::ContactForge::Billing->new;
40my $stripe = MODS::ContactForge::Stripe->new;
41my $DB = $cfg->settings('database_name');
42
43my $dbh = $db->db_connect();
44unless ($dbh) {
45 print STDERR "billing_worker: db_connect failed\n";
46 exit 1;
47}
48unless ($bill->schema_ready($db, $dbh, $DB)) {
49 print STDERR "billing_worker: billing schema not ready, exiting\n";
50 $db->db_disconnect($dbh);
51 exit 0;
52}
53
54# Cheap log line to stdout -- visible in Plesk's cron mail / `tail` of
55# the cron log. Keeps the format stable for easy grep.
56sub _log {
57 my ($msg) = @_;
58 my @t = localtime();
59 printf "[%04d-%02d-%02d %02d:%02d:%02d] %s\n",
60 $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0], $msg;
61}
62
63# Pass 1: apply any deferred downgrades that came due. This is what
64# billing.cgi calls opportunistically on page-load; running it here
65# ensures inactive reps' downgrades still land on schedule.
66my @apply_rows = $db->db_readwrite_multiple($dbh, qq~
67 SELECT DISTINCT user_id FROM `${DB}`.subscriptions
68 WHERE pending_plan_id IS NOT NULL
69 AND pending_change_at IS NOT NULL
70 AND pending_change_at <= NOW()
71~, $ENV{SCRIPT_NAME}, __LINE__);
72foreach my $row (@apply_rows) {
73 my $n = $bill->apply_pending_changes_if_due($db, $dbh, $DB, $row->{user_id});
74 _log("applied $n pending plan change(s) for user $row->{user_id}") if $n;
75}
76
77# Pass 1.5: promote every trialing -> active where trial_end has
78# passed, across ALL users. Calling with uid=0 keeps the helper
79# generic. After promotion these rows fall into the standard
80# next_due_subscriptions() net below for their first paid invoice.
81my $promoted = $bill->_promote_expired_trials($db, $dbh, $DB, 0);
82_log("promoted $promoted expired trial(s) to active") if $promoted;
83
84# Pass 2: invoice + charge every due subscription. Bounded batch so
85# we don't tie up the worker for too long on a slow Stripe response.
86my @due = $bill->next_due_subscriptions($db, $dbh, $DB, 100);
87my $count_attempted = 0;
88my $count_paid = 0;
89my $count_failed = 0;
90
91foreach my $sub (@due) {
92 $count_attempted++;
93 my $sid = $sub->{subscription_id};
94 my $uid = $sub->{user_id};
95
96 my $invoice_id = $bill->generate_invoice($db, $dbh, $DB, $sub);
97 unless ($invoice_id) {
98 _log("sub=$sid user=$uid: could not generate invoice (zero-amount plan or schema gap)");
99 next;
100 }
101
102 # Apply credit BEFORE attempting the charge -- a fully-credited
103 # invoice closes without ever hitting Stripe.
104 $bill->apply_credit_to_invoice($db, $dbh, $DB, $invoice_id);
105
106 my $result = $bill->charge_invoice($db, $dbh, $DB, $invoice_id, $stripe);
107 my $mode = $result->{mode} || 'noop';
108
109 if ($mode eq 'paid_by_credit') {
110 $count_paid++;
111 _log("sub=$sid user=$uid invoice=$invoice_id: paid by credit");
112 } elsif ($mode eq 'paid_by_card') {
113 $count_paid++;
114 _log("sub=$sid user=$uid invoice=$invoice_id: charged " . ($result->{payment_intent_id} || ''));
115 } elsif ($mode eq 'failed') {
116 $count_failed++;
117 _log("sub=$sid user=$uid invoice=$invoice_id: FAILED (" . ($result->{reason} || '?') . ')');
118 } else {
119 _log("sub=$sid user=$uid invoice=$invoice_id: noop (" . ($result->{reason} || '?') . ')');
120 }
121}
122
123_log("done: attempted=$count_attempted paid=$count_paid failed=$count_failed");
124
125$db->db_disconnect($dbh);
126exit 0;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help