| @@ -254,10 +254,20 @@ | ||
| 254 | 254 | |
| 255 | 255 | $transactionId = $transactionData['id']; |
| 256 | 256 | $oldTransaction = Transaction::find($transactionId); |
| 257 | 257 | |
| 258 | + if (!$oldTransaction) { | |
| 259 | + wp_send_json_error(['message' => __('Transaction not found.', 'fluentform')], 404); | |
| 260 | + } | |
| 261 | + | |
| 258 | 262 | $changingStatus = $oldTransaction->status != $transactionData['status']; |
| 259 | 263 | |
| 264 | + // Only a *changed* status is validated; a row may already hold one this build does not | |
| 265 | + // register, e.g. Pro's 'requires_review', and editing other fields must not be blocked. | |
| 266 | + if ($changingStatus && !isset(PaymentHelper::getPaymentStatuses()[$transactionData['status']])) { | |
| 267 | + wp_send_json_error(['message' => __('Invalid payment status.', 'fluentform')], 422); | |
| 268 | + } | |
| 269 | + | |
| 260 | 270 | $updateData = ArrayHelper::only($transactionData, [ |
| 261 | 271 | 'payer_name', |
| 262 | 272 | 'payer_email', |
| 263 | 273 | 'billing_address', |
| @@ -271,15 +281,21 @@ | ||
| 271 | 281 | Transaction::where('id', $transactionId)->update($updateData); |
| 272 | 282 | |
| 273 | 283 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration |
| 274 | 284 | if ($subscriptionId) { |
| 275 | - $existingSubscription = Subscription::find($subscriptionId); | |
| 285 | + // Bind to the transaction's submission; submission_id comes from the row, not the request. | |
| 286 | + $existingSubscription = Subscription::where('id', $subscriptionId) | |
| 287 | + ->where('submission_id', $oldTransaction->submission_id) | |
| 288 | + ->first(); | |
| 276 | 289 | |
| 277 | 290 | $changedStatus = ArrayHelper::get($transactionData, 'status'); |
| 278 | 291 | |
| 279 | - $isStatusChanged = $existingSubscription->status != $changedStatus; | |
| 292 | + // Only mirror real subscription statuses; 'paid' here would block cancellation forever. | |
| 293 | + $isMappable = $existingSubscription | |
| 294 | + && isset(PaymentHelper::getSubscriptionStatuses()[$changedStatus]) | |
| 295 | + && $existingSubscription->status != $changedStatus; | |
| 280 | 296 | |
| 281 | - if ($isStatusChanged) { | |
| 297 | + if ($isMappable) { | |
| 282 | 298 | Subscription::where('id', $subscriptionId) |
| 283 | 299 | ->update([ |
| 284 | 300 | 'status' => $changedStatus, |
| 285 | 301 | 'updated_at' => current_time('mysql') |
| @@ -295,10 +311,10 @@ | ||
| 295 | 311 | } |
| 296 | 312 | }; |
| 297 | 313 | |
| 298 | 314 | if ( |
| 299 | - ($changingStatus && ($newStatus == 'refunded' || $newStatus == 'partial-refunded')) || | |
| 300 | - ($newStatus == 'partial-refunded' && ArrayHelper::get($transactionData, 'refund_amount')) | |
| 315 | + ($changingStatus && ($newStatus == 'refunded' || $newStatus == 'partially-refunded')) || | |
| 316 | + ($newStatus == 'partially-refunded' && ArrayHelper::get($transactionData, 'refund_amount')) | |
| 301 | 317 | ) { |
| 302 | 318 | $refundAmount = 0; |
| 303 | 319 | $refundNote = 'Refunded by Admin'; |
| 304 | 320 | |
| @@ -306,9 +322,9 @@ | ||
| 306 | 322 | // Handle refund here |
| 307 | 323 | $refundAmount = $oldTransaction->payment_total; |
| 308 | 324 | } else if ($newStatus == 'partially-refunded') { |
| 309 | 325 | $refundAmount = ArrayHelper::get($transactionData, 'refund_amount') * 100; |
| 310 | - $refundNote = ArrayHelper::get($transactionData, 'refund_note'); | |
| 326 | + $refundNote = ArrayHelper::get($transactionData, 'refund_note') ?: $refundNote; | |
| 311 | 327 | } |
| 312 | 328 | |
| 313 | 329 | if ($refundAmount) { |
| 314 | 330 | $baseProcessor->setSubmissionId($oldTransaction->submission_id); |
| @@ -314,8 +330,12 @@ | ||
| 314 | 330 | $baseProcessor->setSubmissionId($oldTransaction->submission_id); |
| 315 | 331 | |
| 316 | 332 | $submission = $baseProcessor->getSubmission(); |
| 317 | 333 | $baseProcessor->refund($refundAmount, $oldTransaction, $submission, $oldTransaction->payment_method, 'refund_' . time(), $refundNote); |
| 334 | + | |
| 335 | + // refund() derives the real status from the refunded total: an amount covering | |
| 336 | + // the whole charge is a full refund, whatever status was requested. | |
| 337 | + $newStatus = Transaction::find($transactionId)->status; | |
| 318 | 338 | } |
| 319 | 339 | |
| 320 | 340 | } |
| 321 | 341 | |
| @@ -321,10 +341,10 @@ | ||
| 321 | 341 | |
| 322 | 342 | if ($changingStatus) { |
| 323 | 343 | |
| 324 | 344 | if ($newStatus == 'paid' || $newStatus == 'pending' || $newStatus == 'processing') { |
| 325 | - // Delete All Refunds | |
| 326 | - Transaction::bySubmission($oldTransaction->submission_id)->refunds()->delete(); | |
| 345 | + // Delete All Refunds, recording them first | |
| 346 | + $this->recordAndRemoveRefundLedger($oldTransaction, $newStatus); | |
| 327 | 347 | } |
| 328 | 348 | |
| 329 | 349 | $baseProcessor->setSubmissionId($oldTransaction->submission_id); |
| 330 | 350 | $baseProcessor->changeSubmissionPaymentStatus($newStatus); |
| @@ -348,8 +368,57 @@ | ||
| 348 | 368 | |
| 349 | 369 | wp_send_json_success([ |
| 350 | 370 | 'message' => __('Successfully updated data', 'fluentform') |
| 351 | 371 | ], 200); |
| 372 | + } | |
| 373 | + | |
| 374 | + /** | |
| 375 | + * The record is the compensating control for an irreversible delete, so if it cannot be written the rows must survive. | |
| 376 | + */ | |
| 377 | + private function recordAndRemoveRefundLedger($oldTransaction, $newStatus) | |
| 378 | + { | |
| 379 | + $refunds = Transaction::bySubmission($oldTransaction->submission_id)->refunds()->get(); | |
| 380 | + | |
| 381 | + if (!count($refunds)) { | |
| 382 | + return; | |
| 383 | + } | |
| 384 | + | |
| 385 | + $ids = []; | |
| 386 | + $total = 0; | |
| 387 | + $records = []; | |
| 388 | + | |
| 389 | + foreach ($refunds as $refund) { | |
| 390 | + $ids[] = $refund->id; | |
| 391 | + $total += $refund->payment_total; | |
| 392 | + $records[] = '#' . $refund->id . ' (' . PaymentHelper::formatMoney($refund->payment_total, $refund->currency) . ')'; | |
| 393 | + } | |
| 394 | + | |
| 395 | + $description = sprintf( | |
| 396 | + /* translators: 1: previous status, 2: new status, 3: number of refund records, 4: formatted total, 5: the deleted records */ | |
| 397 | + __( | |
| 398 | + 'Payment status changed from %1$s to %2$s, which removed %3$d refund record(s) totalling %4$s: %5$s', | |
| 399 | + 'fluentform' | |
| 400 | + ), | |
| 401 | + $oldTransaction->status, | |
| 402 | + $newStatus, | |
| 403 | + count($refunds), | |
| 404 | + PaymentHelper::formatMoney($total, $oldTransaction->currency), | |
| 405 | + implode(', ', $records) | |
| 406 | + ); | |
| 407 | + | |
| 408 | + // Record first: if this write fails the rows are still here to try again. | |
| 409 | + do_action('fluentform/log_data', [ | |
| 410 | + 'parent_source_id' => $oldTransaction->form_id, | |
| 411 | + 'source_type' => 'submission_item', | |
| 412 | + 'source_id' => $oldTransaction->submission_id, | |
| 413 | + 'component' => 'Payment', | |
| 414 | + 'status' => 'info', | |
| 415 | + 'title' => __('Refund records deleted', 'fluentform'), | |
| 416 | + 'description' => $description, | |
| 417 | + ]); | |
| 418 | + | |
| 419 | + // Only the rows just recorded, so a refund added meanwhile is not destroyed unrecorded. | |
| 420 | + Transaction::whereIn('id', $ids)->delete(); | |
| 352 | 421 | } |
| 353 | 422 | |
| 354 | 423 | public function getStripeConnectConfig() |
| 355 | 424 | { |