← All changes
|
app/Modules/Payments/PaymentMethods/BaseProcessor.php
+156
-16
6.2.5
→
6.2.14
View file →
| @@ -52,9 +52,9 @@ | ||
| 52 | 52 | |
| 53 | 53 | $data = wp_parse_args($data, $this->getTransactionDefaults()); |
| 54 | 54 | |
| 55 | 55 | if (empty($data['transaction_hash'])) { |
| 56 | - $data['transaction_hash'] = md5($data['transaction_type'] . '_payment_' . $data['submission_id'] . '-' . $data['form_id'] . '_' . $data['created_at'] . '-' . time() . '-' . wp_rand(100, 999)); | |
| 56 | + $data['transaction_hash'] = wp_generate_password(32, false); | |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | return Transaction::create($data)->id; |
| 60 | 60 | } |
| @@ -75,9 +75,9 @@ | ||
| 75 | 75 | $data['user_id'] = $userId; |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | 78 | if (empty($data['transaction_hash'])) { |
| 79 | - $data['transaction_hash'] = md5($data['transaction_type'] . '_payment_' . $data['submission_id'] . '-' . $data['form_id'] . '_' . $data['created_at'] . '-' . time() . '-' . wp_rand(100, 999)); | |
| 79 | + $data['transaction_hash'] = wp_generate_password(32, false); | |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | return Transaction::create($data)->id; |
| 83 | 83 | } |
| @@ -102,9 +102,11 @@ | ||
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | public function getLastTransaction($submissionId) |
| 105 | 105 | { |
| 106 | + // was: any row, so a refund ledger row inserted meanwhile became the row being settled | |
| 106 | 107 | return Transaction::bySubmission($submissionId) |
| 108 | + ->where('transaction_type', '!=', 'refund') | |
| 107 | 109 | ->orderBy('id', 'DESC') |
| 108 | 110 | ->first(); |
| 109 | 111 | } |
| 110 | 112 | |
| @@ -109,8 +111,18 @@ | ||
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | public function changeSubmissionPaymentStatus($newStatus) |
| 112 | 114 | { |
| 115 | + return $this->writeSubmissionPaymentStatus($newStatus, false); | |
| 116 | + } | |
| 117 | + | |
| 118 | + public function changeSubmissionPaymentStatusUnlessReversed($newStatus) | |
| 119 | + { | |
| 120 | + return $this->writeSubmissionPaymentStatus($newStatus, true); | |
| 121 | + } | |
| 122 | + | |
| 123 | + protected function writeSubmissionPaymentStatus($newStatus, $unlessReversed) | |
| 124 | + { | |
| 113 | 125 | do_action_deprecated( |
| 114 | 126 | 'fluentform_before_payment_status_change', |
| 115 | 127 | [ |
| 116 | 128 | $newStatus, |
| @@ -123,8 +135,11 @@ | ||
| 123 | 135 | |
| 124 | 136 | do_action('fluentform/before_payment_status_change', $newStatus, $this->getSubmission()); |
| 125 | 137 | |
| 126 | 138 | Submission::where('id', $this->submissionId) |
| 139 | + ->when($unlessReversed, function ($query) { | |
| 140 | + $query->whereNotIn('payment_status', PaymentHelper::reversedPaymentStatuses()); | |
| 141 | + }) | |
| 127 | 142 | ->update([ |
| 128 | 143 | 'payment_status' => $newStatus, |
| 129 | 144 | 'updated_at' => current_time('mysql') |
| 130 | 145 | ]); |
| @@ -130,8 +145,16 @@ | ||
| 130 | 145 | ]); |
| 131 | 146 | |
| 132 | 147 | $this->submission = null; |
| 133 | 148 | |
| 149 | + // A reversal recorded between the caller's read and this write keeps the row; the caller decides | |
| 150 | + if ($unlessReversed) { | |
| 151 | + $submission = $this->getSubmission(); | |
| 152 | + if (!$submission || PaymentHelper::isReversedPaymentStatus($submission->payment_status)) { | |
| 153 | + return false; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 134 | 157 | $logData = [ |
| 135 | 158 | 'parent_source_id' => $this->getForm()->id, |
| 136 | 159 | 'source_type' => 'submission_item', |
| 137 | 160 | 'source_id' => $this->submissionId, |
| @@ -158,23 +181,31 @@ | ||
| 158 | 181 | |
| 159 | 182 | return true; |
| 160 | 183 | } |
| 161 | 184 | |
| 185 | + protected function shouldRunSubmissionActions() | |
| 186 | + { | |
| 187 | + $current = Submission::find($this->submissionId); | |
| 188 | + | |
| 189 | + return $current && apply_filters('fluentform/should_process_submission_actions', true, $current, $this->getForm()); | |
| 190 | + } | |
| 191 | + | |
| 162 | 192 | public function recalculatePaidTotal() |
| 163 | 193 | { |
| 194 | + // was: the paid scope also matched the refund rows themselves (status refunded), so a full refund | |
| 195 | + // summed gross + refund - refund and total_paid stayed at the gross amount | |
| 164 | 196 | $transactions = Transaction::bySubmission($this->submissionId) |
| 165 | 197 | ->paid() |
| 198 | + ->where('transaction_type', '!=', 'refund') | |
| 166 | 199 | ->get(); |
| 167 | 200 | |
| 168 | 201 | $total = 0; |
| 169 | 202 | $subscriptionId = false; |
| 170 | - $subBillCount = 0; | |
| 171 | 203 | |
| 172 | 204 | foreach ($transactions as $transaction) { |
| 173 | 205 | $total += $transaction->payment_total; |
| 174 | 206 | if($transaction->subscription_id) { |
| 175 | 207 | $subscriptionId = $transaction->subscription_id; |
| 176 | - $subBillCount += 1; | |
| 177 | 208 | } |
| 178 | 209 | } |
| 179 | 210 | |
| 180 | 211 | $refunds = $this->getRefundTotal(); |
| @@ -187,11 +218,13 @@ | ||
| 187 | 218 | 'total_paid' => $total, |
| 188 | 219 | 'updated_at' => current_time('mysql') |
| 189 | 220 | ]); |
| 190 | 221 | |
| 191 | - if($subscriptionId && $subBillCount) { | |
| 222 | + // was: its own count of every paid-scope row here, so a $0 trial start became installment one | |
| 223 | + if ($subscriptionId) { | |
| 224 | + list($installmentCount) = $this->getPaymentCountsAndTotal($subscriptionId); | |
| 192 | 225 | $this->updateSubscription($subscriptionId, [ |
| 193 | - 'bill_count' => $subBillCount | |
| 226 | + 'bill_count' => $installmentCount | |
| 194 | 227 | ]); |
| 195 | 228 | } |
| 196 | 229 | } |
| 197 | 230 | |
| @@ -210,8 +243,18 @@ | ||
| 210 | 243 | } |
| 211 | 244 | |
| 212 | 245 | public function changeTransactionStatus($transactionId, $newStatus) |
| 213 | 246 | { |
| 247 | + return $this->writeTransactionStatus($transactionId, $newStatus, false); | |
| 248 | + } | |
| 249 | + | |
| 250 | + public function changeTransactionStatusUnlessReversed($transactionId, $newStatus) | |
| 251 | + { | |
| 252 | + return $this->writeTransactionStatus($transactionId, $newStatus, true); | |
| 253 | + } | |
| 254 | + | |
| 255 | + protected function writeTransactionStatus($transactionId, $newStatus, $unlessReversed) | |
| 256 | + { | |
| 214 | 257 | do_action_deprecated( |
| 215 | 258 | 'fluentform_before_transaction_status_change', |
| 216 | 259 | [ |
| 217 | 260 | $newStatus, |
| @@ -230,13 +273,23 @@ | ||
| 230 | 273 | $transactionId |
| 231 | 274 | ); |
| 232 | 275 | |
| 233 | 276 | Transaction::where('id', $transactionId) |
| 277 | + ->when($unlessReversed, function ($query) { | |
| 278 | + $query->whereNotIn('status', PaymentHelper::reversedPaymentStatuses()); | |
| 279 | + }) | |
| 234 | 280 | ->update([ |
| 235 | 281 | 'status' => $newStatus, |
| 236 | 282 | 'updated_at' => current_time('mysql') |
| 237 | 283 | ]); |
| 238 | 284 | |
| 285 | + if ($unlessReversed) { | |
| 286 | + $transaction = $this->getTransaction($transactionId); | |
| 287 | + if (!$transaction || PaymentHelper::isReversedPaymentStatus($transaction->status)) { | |
| 288 | + return false; | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 239 | 292 | do_action_deprecated( |
| 240 | 293 | 'fluentform_after_transaction_status_change', |
| 241 | 294 | [ |
| 242 | 295 | $newStatus, |
| @@ -294,8 +347,16 @@ | ||
| 294 | 347 | if ($loginId = $this->getMetaData('_make_auto_login')) { |
| 295 | 348 | $this->maybeAutoLogin($loginId, $submission); |
| 296 | 349 | } |
| 297 | 350 | } |
| 351 | + } elseif (!$this->shouldRunSubmissionActions()) { | |
| 352 | + // was: fired the actions unconditionally; a reversal recorded since the paid write is | |
| 353 | + // skipped here as it is on the deferred pipeline (PaymentHandler::skipActionsForReversedPayment) | |
| 354 | + $returnData = [ | |
| 355 | + 'insert_id' => $submission->id, | |
| 356 | + 'result' => $submissionService->getReturnData($submission->id, $this->getForm(), $submission->response), | |
| 357 | + 'error' => '', | |
| 358 | + ]; | |
| 298 | 359 | } else { |
| 299 | 360 | $returnData = $submissionService->processSubmissionData( |
| 300 | 361 | $this->submissionId, $submission->response, $this->getForm() |
| 301 | 362 | ); |
| @@ -475,8 +536,23 @@ | ||
| 475 | 536 | $this->setSubmissionId($submission->id); |
| 476 | 537 | $status = 'refunded'; |
| 477 | 538 | |
| 478 | 539 | $alreadyRefunded = $this->getRefundTotal(); |
| 540 | + | |
| 541 | + // Submission-wide, matching getRefundTotal(); a subscription's invoices share one submission. | |
| 542 | + $charged = (int) Transaction::bySubmission($submission->id) | |
| 543 | + ->where('transaction_type', '!=', 'refund') | |
| 544 | + ->whereIn('status', ['paid', 'processing', 'refunded', 'partially-refunded']) | |
| 545 | + ->sum('payment_total'); | |
| 546 | + | |
| 547 | + // round(), not intval(): gateways pass floats (PayPal does mc_gross * -100) and | |
| 548 | + // 19.99 * 100 is 1998.9999... - truncating would shave a cent off the refund. | |
| 549 | + $refund_amount = min((int) round($refund_amount), max(0, $charged - (int) round($alreadyRefunded))); | |
| 550 | + | |
| 551 | + if ($refund_amount <= 0) { | |
| 552 | + return; | |
| 553 | + } | |
| 554 | + | |
| 479 | 555 | $totalRefund = intval($refund_amount + $alreadyRefunded); |
| 480 | 556 | |
| 481 | 557 | if ($totalRefund < $transaction->payment_total) { |
| 482 | 558 | $status = 'partially-refunded'; |
| @@ -483,9 +559,9 @@ | ||
| 483 | 559 | } |
| 484 | 560 | |
| 485 | 561 | $this->changeTransactionStatus($transaction->id, $status); |
| 486 | 562 | $this->changeSubmissionPaymentStatus($status); |
| 487 | - $uniqueHash = md5('refund_' . $submission->id . '-' . $submission->form_id . '-' . time() . '-' . wp_rand(100, 999)); | |
| 563 | + $uniqueHash = wp_generate_password(32, false); | |
| 488 | 564 | |
| 489 | 565 | $refundData = [ |
| 490 | 566 | 'form_id' => $submission->form_id, |
| 491 | 567 | 'submission_id' => $submission->id, |
| @@ -736,8 +812,13 @@ | ||
| 736 | 812 | |
| 737 | 813 | unset($item['transaction_hash']); |
| 738 | 814 | unset($item['created_at']); |
| 739 | 815 | |
| 816 | + // was: overwrote the status unconditionally, so a replayed success event un-refunded the row | |
| 817 | + if (PaymentHelper::isReversedPaymentStatus($exists->status)) { | |
| 818 | + unset($item['status']); | |
| 819 | + } | |
| 820 | + | |
| 740 | 821 | Transaction::where('id', $exists->id)->update($item); |
| 741 | 822 | |
| 742 | 823 | $id = $exists->id; |
| 743 | 824 | } else { |
| @@ -746,9 +827,9 @@ | ||
| 746 | 827 | $item['updated_at'] = current_time('mysql'); |
| 747 | 828 | } |
| 748 | 829 | |
| 749 | 830 | if (empty($item['transaction_hash'])) { |
| 750 | - $uniqueHash = md5('subscription_payment_' . $item['submission_id'] . '-' . $item['charge_id'] . '-' . time() . '-' . wp_rand(100, 999)); | |
| 831 | + $uniqueHash = wp_generate_password(32, false); | |
| 751 | 832 | $item['transaction_hash'] = $uniqueHash; |
| 752 | 833 | } |
| 753 | 834 | |
| 754 | 835 | $id = Transaction::create($item)->id; |
| @@ -771,17 +852,22 @@ | ||
| 771 | 852 | 'payment_total' => $paymentTotal, |
| 772 | 853 | 'updated_at' => current_time('mysql') |
| 773 | 854 | ]); |
| 774 | 855 | |
| 856 | + // was: total_paid took the gross subscription sum here, so a renewal or a replayed invoice | |
| 857 | + // after a refund put the refunded amount back; recalculatePaidTotal() is the net authority | |
| 775 | 858 | Submission::where('id', $parentSubscription->submission_id) |
| 776 | 859 | ->update([ |
| 777 | 860 | 'payment_total' => $paymentTotal, |
| 778 | - 'total_paid' => $paymentTotal, | |
| 779 | 861 | ]); |
| 862 | + $this->recalculatePaidTotal(); | |
| 780 | 863 | |
| 781 | 864 | $subscription = Subscription::where('id', $parentSubscription->id)->first(); |
| 782 | 865 | |
| 783 | - if($isNew) { | |
| 866 | + // Keep a missing 6.2.13 renewal without reviving a closed subscription. | |
| 867 | + $isTerminalSubscription = in_array($subscription->status, ['cancelled', 'completed'], true); | |
| 868 | + | |
| 869 | + if ($isNew && !$isTerminalSubscription) { | |
| 784 | 870 | $submission = $this->getSubmission(); |
| 785 | 871 | do_action_deprecated( |
| 786 | 872 | 'fluentform_subscription_received_payment', |
| 787 | 873 | [ |
| @@ -806,9 +892,22 @@ | ||
| 806 | 892 | ); |
| 807 | 893 | do_action('fluentform/subscription_received_payment_' . $submission->payment_method, $subscription, $submission); |
| 808 | 894 | } |
| 809 | 895 | |
| 810 | - if($subscription->bill_times >= $subscription->bill_count) { | |
| 896 | + if ($isNew && $isTerminalSubscription) { | |
| 897 | + $submission = $this->getSubmission(); | |
| 898 | + do_action('fluentform/log_data', [ | |
| 899 | + 'parent_source_id' => $submission->form_id, | |
| 900 | + 'source_type' => 'submission_item', | |
| 901 | + 'source_id' => $submission->id, | |
| 902 | + 'component' => 'Payment', | |
| 903 | + 'status' => 'info', | |
| 904 | + 'title' => __('Late subscription payment recorded', 'fluentform'), | |
| 905 | + 'description' => __('The subscription is already closed, so no renewal actions were fired.', 'fluentform'), | |
| 906 | + ]); | |
| 907 | + } | |
| 908 | + | |
| 909 | + if (!$isTerminalSubscription && $subscription->bill_times > 0 && $subscription->bill_count >= $subscription->bill_times) { | |
| 811 | 910 | // We have to mark the subscription as completed |
| 812 | 911 | $this->updateSubscriptionStatus($subscription, 'completed'); |
| 813 | 912 | } |
| 814 | 913 | } |
| @@ -817,25 +916,38 @@ | ||
| 817 | 916 | } |
| 818 | 917 | |
| 819 | 918 | public function getPaymentCountsAndTotal($subscriptionId, $paymentMethod = false) |
| 820 | 919 | { |
| 821 | - $payments = Transaction::select(['id', 'payment_total']) | |
| 920 | + // was: every row of any status, so a pending echeck or a failed attempt counted as an installment | |
| 921 | + $payments = Transaction::select(['id', 'payment_method', 'payment_total', 'payment_note']) | |
| 822 | 922 | ->subscriptionType() |
| 823 | 923 | ->where('subscription_id', $subscriptionId) |
| 924 | + ->whereIn('status', ['paid', 'partially-refunded', 'refunded']) | |
| 824 | 925 | ->when($paymentMethod, function ($q) use ($paymentMethod) { |
| 825 | 926 | $q->where('payment_method', $paymentMethod); |
| 826 | 927 | }) |
| 827 | 928 | ->get(); |
| 828 | 929 | |
| 930 | + $subscription = Subscription::find($subscriptionId); | |
| 931 | + | |
| 932 | + $installmentCount = 0; | |
| 829 | 933 | $paymentTotal = 0; |
| 830 | 934 | |
| 831 | 935 | foreach ($payments as $payment) { |
| 832 | 936 | $paymentTotal += $payment->payment_total; |
| 937 | + if ($this->isCountableSubscriptionPayment($payment, $subscription)) { | |
| 938 | + $installmentCount++; | |
| 939 | + } | |
| 833 | 940 | } |
| 834 | 941 | |
| 835 | - return [count($payments), $paymentTotal]; | |
| 942 | + return [$installmentCount, $paymentTotal]; | |
| 836 | 943 | } |
| 837 | 944 | |
| 945 | + protected function isCountableSubscriptionPayment($payment, $subscription) | |
| 946 | + { | |
| 947 | + return $payment->payment_total > 0; | |
| 948 | + } | |
| 949 | + | |
| 838 | 950 | protected function getCancelAtTimeStamp($subscription) |
| 839 | 951 | { |
| 840 | 952 | if (!$subscription->bill_times) { |
| 841 | 953 | return false; |
| @@ -955,13 +1067,14 @@ | ||
| 955 | 1067 | } |
| 956 | 1068 | |
| 957 | 1069 | $form = $this->getForm(); |
| 958 | 1070 | |
| 959 | - $uniqueHash = md5($submission->id . '-' . $form->id . '-' . time() . '-' . wp_rand(100, 999)); | |
| 1071 | + $uniqueHash = wp_generate_password(32, false); | |
| 960 | 1072 | |
| 961 | 1073 | $transactionData = [ |
| 962 | 1074 | 'transaction_type' => 'onetime', |
| 963 | 1075 | 'transaction_hash' => $uniqueHash, |
| 1076 | + 'subscription_id' => null, | |
| 964 | 1077 | 'payment_total' => $this->getAmountTotal(), |
| 965 | 1078 | 'status' => 'pending', |
| 966 | 1079 | 'currency' => strtoupper($submission->currency), |
| 967 | 1080 | 'payment_mode' => $this->getPaymentMode() |
| @@ -983,10 +1096,30 @@ | ||
| 983 | 1096 | $transactionData['transaction_type'] = 'subscription'; |
| 984 | 1097 | } |
| 985 | 1098 | } |
| 986 | 1099 | |
| 987 | - $transactionId = $this->insertTransaction($transactionData); | |
| 1100 | + $existingTransaction = Transaction::bySubmission($submission->id) | |
| 1101 | + ->where('payment_method', $this->method) | |
| 1102 | + ->where('transaction_type', '!=', 'refund') | |
| 1103 | + ->where('status', 'pending') | |
| 1104 | + ->orderBy('id', 'DESC') | |
| 1105 | + ->first(); | |
| 988 | 1106 | |
| 1107 | + if ($existingTransaction) { | |
| 1108 | + Transaction::where('id', $existingTransaction->id)->update([ | |
| 1109 | + 'transaction_type' => $transactionData['transaction_type'], | |
| 1110 | + 'subscription_id' => $transactionData['subscription_id'], | |
| 1111 | + 'payment_mode' => $transactionData['payment_mode'], | |
| 1112 | + 'status' => 'pending', | |
| 1113 | + 'payment_total' => $transactionData['payment_total'], | |
| 1114 | + 'currency' => $transactionData['currency'], | |
| 1115 | + 'updated_at' => current_time('mysql') | |
| 1116 | + ]); | |
| 1117 | + $transactionId = $existingTransaction->id; | |
| 1118 | + } else { | |
| 1119 | + $transactionId = $this->insertTransaction($transactionData); | |
| 1120 | + } | |
| 1121 | + | |
| 989 | 1122 | $this->updateSubmission($submission->id, [ |
| 990 | 1123 | 'payment_total' => $transactionData['payment_total'] |
| 991 | 1124 | ]); |
| 992 | 1125 | |
| @@ -1012,9 +1145,16 @@ | ||
| 1012 | 1145 | } |
| 1013 | 1146 | |
| 1014 | 1147 | $oldStatus = $subscription->status; |
| 1015 | 1148 | |
| 1016 | - if($oldStatus == $newStatus) { | |
| 1149 | + if ($oldStatus == $newStatus) { | |
| 1150 | + return $subscription; | |
| 1151 | + } | |
| 1152 | + | |
| 1153 | + // A terminal subscription never moves again: a late/replayed event must not revive | |
| 1154 | + // cancelled -> completed (or completed -> cancelled) and fire its status side effects. | |
| 1155 | + $terminalStatuses = ['cancelled', 'completed']; | |
| 1156 | + if (in_array($oldStatus, $terminalStatuses)) { | |
| 1017 | 1157 | return $subscription; |
| 1018 | 1158 | } |
| 1019 | 1159 | |
| 1020 | 1160 | Subscription::where('id', $subscription->id) |