added on local at 2026-07-01 22:10:08
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ShopCart - 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 seller'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/shop.3dshawn.com/_billing_worker.pl | |
| 23 | # Recommended cron (Plesk Scheduled Tasks): | |
| 24 | # */15 * * * * -- every 15 minutes | |
| 25 | #====================================================================== | |
| 26 | use strict; | |
| 27 | use warnings; | |
| 28 | ||
| 29 | use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com'; | |
| 30 | use MODS::DBConnect; | |
| 31 | use MODS::ShopCart::Config; | |
| 32 | use MODS::ShopCart::Billing; | |
| 33 | use MODS::ShopCart::Stripe; | |
| 34 | ||
| 35 | $|=1; | |
| 36 | ||
| 37 | my $db = MODS::DBConnect->new; | |
| 38 | my $cfg = MODS::ShopCart::Config->new; | |
| 39 | my $bill = MODS::ShopCart::Billing->new; | |
| 40 | my $stripe = MODS::ShopCart::Stripe->new; | |
| 41 | my $DB = $cfg->settings('database_name'); | |
| 42 | ||
| 43 | my $dbh = $db->db_connect(); | |
| 44 | unless ($dbh) { | |
| 45 | print STDERR "billing_worker: db_connect failed\n"; | |
| 46 | exit 1; | |
| 47 | } | |
| 48 | unless ($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. | |
| 56 | sub _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 sellers' downgrades still land on schedule. | |
| 66 | my @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__); | |
| 72 | foreach 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. | |
| 81 | my $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. | |
| 86 | my @due = $bill->next_due_subscriptions($db, $dbh, $DB, 100); | |
| 87 | my $count_attempted = 0; | |
| 88 | my $count_paid = 0; | |
| 89 | my $count_failed = 0; | |
| 90 | ||
| 91 | foreach 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); | |
| 126 | exit 0; |