@@ -218,6 +218,33 @@ function can_use_cc($data, $ccData = false, $check_disabled_cc = true, $cc_field
218218 }
219219 }
220220 }
221+ // A missing or expired expiration date makes a card unusable regardless of whitelist/fraud status.
222+ // Without this, get_next_cc() can hand back an expired or exp-less backup card whose charge then
223+ // falls through to the fabricated fallback expiration date and is guaranteed to decline.
224+ if (is_array ($ ccData )) {
225+ $ cc_exp_raw = isset ($ ccData ['cc_exp ' ]) ? trim ((string ) $ ccData ['cc_exp ' ]) : '' ;
226+ if ($ cc_exp_raw === '' ) {
227+ $ reason .= ' No Credit-Card expiration date set. ' ;
228+ $ cc_usable = false ;
229+ } else {
230+ if (mb_strpos ($ cc_exp_raw , '/ ' ) === false ) {
231+ $ cc_exp_raw = mb_substr ($ cc_exp_raw , 0 , 2 ).'/ ' .mb_substr ($ cc_exp_raw , 2 );
232+ }
233+ $ exp_parts = explode ('/ ' , str_replace (' ' , '' , $ cc_exp_raw ));
234+ $ exp_month = isset ($ exp_parts [0 ]) && $ exp_parts [0 ] !== '' ? (int ) $ exp_parts [0 ] : 0 ;
235+ $ exp_year = $ exp_parts [1 ] ?? '' ;
236+ if (mb_strlen ($ exp_year ) == 2 ) {
237+ $ exp_year = '20 ' .$ exp_year ;
238+ }
239+ $ exp_year = (int ) $ exp_year ;
240+ // only flag when the date parses cleanly and is in the past — never reject an unparseable format
241+ if ($ exp_month >= 1 && $ exp_month <= 12 && $ exp_year >= 2000
242+ && ($ exp_year < (int ) date ('Y ' ) || ($ exp_year == (int ) date ('Y ' ) && $ exp_month < (int ) date ('n ' )))) {
243+ $ reason .= ' Credit-Card has expired ( ' .$ exp_month .'/ ' .$ exp_year .'). ' ;
244+ $ cc_usable = false ;
245+ }
246+ }
247+ }
221248 if ($ set_global_reason === true ) {
222249 $ GLOBALS ['cc_reason ' ] = trim ($ reason );
223250 }
@@ -345,6 +372,20 @@ function charge_card($custid, $amount = false, $invoice = false, $module = 'defa
345372 $ response ['code ' ] = 0 ;
346373 //$cc = $data['cc'];
347374 $ ccs = parse_ccs ($ data );
375+ // The "No CC Expiration Date On File" guard above only fires on the primary-card path (no ot_cc).
376+ // Mirror it for the backup-card (ot_cc) retry path: if the selected backup card has no usable
377+ // expiration date, abort instead of charging with the fabricated fallback exp (which always declines).
378+ if (isset (App::variables ()->request ['ot_cc ' ])) {
379+ $ ot_cc_idx = App::variables ()->request ['ot_cc ' ];
380+ if (!isset ($ ccs [$ ot_cc_idx ]) || !is_array ($ ccs [$ ot_cc_idx ]) || !isset ($ ccs [$ ot_cc_idx ]['cc_exp ' ]) || trim ((string ) $ ccs [$ ot_cc_idx ]['cc_exp ' ]) == '' ) {
381+ global $ webpage ;
382+ if (isset ($ webpage ) && $ webpage == true ) {
383+ add_output ('<div class="container alert alert-danger"><strong>Error! No CC Expiration Date On File! </strong>We have no credit-card exp date on file. Please go to Billing -> Manage Credit Cards and set one or contact support for assistance.</div> ' );
384+ }
385+ myadmin_log ('billing ' , 'notice ' , "Aborting charge for customer {$ custid }: selected backup card (ot_cc= {$ ot_cc_idx }) has no expiration date on file " , __LINE__ , __FILE__ );
386+ return $ retval ;
387+ }
388+ }
348389 $ cc = isset (App::variables ()->request ['ot_cc ' ]) && isset ($ ccs [App::variables ()->request ['ot_cc ' ]]) && isset ($ ccs [App::variables ()->request ['ot_cc ' ]]['cc ' ]) ? App::decrypt ($ ccs [App::variables ()->request ['ot_cc ' ]]['cc ' ]) : (isset ($ data ['cc ' ]) ? App::decrypt ($ data ['cc ' ]) : null );
349390 if (is_null ($ cc )) {
350391 if (isset ($ webpage ) && $ webpage == true ) {
@@ -784,6 +825,10 @@ function retry_charge_card($custid, $amount = false, $invoice = false, $module =
784825 App::variables ()->request ['ot_cc ' ] = $ next_cc ;
785826 App::variables ()->request ['retry_cc ' ] = 1 ;
786827 $ success = charge_card ($ custid , $ amount , $ invoice , $ module , $ returnURL , $ useHandlePayment , $ queue );
828+ // Clear the retry markers so they cannot leak into the next customer charged in the same
829+ // process (e.g. the billingd loop), which would otherwise index into the wrong customer's
830+ // ccs array and charge their primary card with the fabricated fallback expiration date.
831+ unset(App::variables ()->request ['ot_cc ' ], App::variables ()->request ['retry_cc ' ]);
787832 if ($ success ) {
788833 myadmin_log ('billing ' , 'info ' , "Retrying BackupCC - Success for $ custid, Amount: $ amount, CC ID - $ next_cc " , __LINE__ , __FILE__ );
789834 return true ;
0 commit comments