| @@ -616,10 +616,12 @@ | ||
| 616 | 616 | ); |
| 617 | 617 | } |
| 618 | 618 | |
| 619 | 619 | // Idempotency: refuse re-approval of a booking that already finalized. |
| 620 | + // 'failed' is deliberately not in this list — a declined card is a failed | |
| 621 | + // attempt, not a finished booking, and the customer retries on the same one. | |
| 620 | 622 | $current_status = (string) $booking->get_status(); |
| 621 | - $finalized_statuses = [ 'approved', 'completed', 'failed', 'cancelled', 'cancel' ]; | |
| 623 | + $finalized_statuses = [ 'approved', 'completed', 'cancelled', 'cancel' ]; | |
| 622 | 624 | if ( in_array( $current_status, $finalized_statuses, true ) ) { |
| 623 | 625 | return new WP_HTTP_Response( |
| 624 | 626 | [ |
| 625 | 627 | 'success' => 0, |
| @@ -717,8 +719,18 @@ | ||
| 717 | 719 | } |
| 718 | 720 | } elseif ( 'failed' === $client_status ) { |
| 719 | 721 | // Marking the user's own attempt as failed never grants access; safe to honor. |
| 720 | 722 | $verified_status = 'failed'; |
| 723 | + } else { | |
| 724 | + // Gateways that live outside this plugin ( PayPal ) check the payment | |
| 725 | + // against their own API and answer with the status they trust. The | |
| 726 | + // default stays 'pending', so a client that sends nothing verifiable | |
| 727 | + // cannot talk its way to 'succeeded'. | |
| 728 | + $verified_status = (string) apply_filters( 'timetics_verify_payment', $verified_status, $payment_method, $data, $booking ); | |
| 729 | + | |
| 730 | + if ( ! in_array( $verified_status, ['pending', 'failed', 'succeeded'], true ) ) { | |
| 731 | + $verified_status = 'pending'; | |
| 732 | + } | |
| 721 | 733 | } |
| 722 | 734 | // Other payment methods (cash, on-site, etc.) stay pending here. They |
| 723 | 735 | // are approved through their own authenticated/admin paths. |
| 724 | 736 | $post_status = 'succeeded' === $verified_status |
| @@ -727,11 +739,14 @@ | ||
| 727 | 739 | |
| 728 | 740 | $finalizing = 'succeeded' === $verified_status && '' !== $stored_intent_id; |
| 729 | 741 | |
| 730 | 742 | if ( $finalizing ) { |
| 731 | - $claimed = add_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id, true ); | |
| 743 | + // Separate key from _tt_stripe_payment_intent_id: that one is written at | |
| 744 | + // bind time (before payment) so the cleanup sweep can see it, so it can't | |
| 745 | + // double as a "not yet finalized" marker here — it always already exists. | |
| 746 | + $claimed = add_post_meta( $booking_id, '_tt_stripe_payment_finalized_intent_id', $stored_intent_id, true ); | |
| 732 | 747 | if ( false === $claimed ) { |
| 733 | - $existing = (string) get_post_meta( $booking_id, '_tt_stripe_payment_intent_id', true ); | |
| 748 | + $existing = (string) get_post_meta( $booking_id, '_tt_stripe_payment_finalized_intent_id', true ); | |
| 734 | 749 | if ( $existing !== $stored_intent_id ) { |
| 735 | 750 | return new WP_HTTP_Response( |
| 736 | 751 | [ |
| 737 | 752 | 'success' => 0, |
| @@ -766,9 +781,9 @@ | ||
| 766 | 781 | |
| 767 | 782 | if ( is_wp_error( $update ) ) { |
| 768 | 783 | // Roll back the claim so a retry can finalize cleanly. |
| 769 | 784 | if ( $finalizing ) { |
| 770 | - delete_post_meta( $booking_id, '_tt_stripe_payment_intent_id', $stored_intent_id ); | |
| 785 | + delete_post_meta( $booking_id, '_tt_stripe_payment_finalized_intent_id', $stored_intent_id ); | |
| 771 | 786 | } |
| 772 | 787 | return new WP_HTTP_Response( |
| 773 | 788 | [ |
| 774 | 789 | 'success' => 0, |
| @@ -785,9 +800,14 @@ | ||
| 785 | 800 | if ( 'failed' === $post_status ) { |
| 786 | 801 | $booking->release_slot(); |
| 787 | 802 | } |
| 788 | 803 | |
| 789 | - if ( $default_booking_status === $post_status ) { | |
| 804 | + // Approve, notify and burn the token only when the payment actually | |
| 805 | + // cleared. This used to compare $post_status against the site default, | |
| 806 | + // which is the very same string on a site whose default booking status | |
| 807 | + // is 'pending' - so an unverified attempt still sent the "meeting | |
| 808 | + // scheduled" emails and rotated the token without a penny being paid. | |
| 809 | + if ( 'succeeded' === $verified_status ) { | |
| 790 | 810 | // Rotate the security token so the same one cannot drive a second |
| 791 | 811 | // approval after this booking has finalized. |
| 792 | 812 | $booking->rotate_security_token(); |
| 793 | 813 | |
| @@ -915,9 +935,23 @@ | ||
| 915 | 935 | } else { |
| 916 | 936 | $status = $default_status; |
| 917 | 937 | } |
| 918 | 938 | } else { |
| 919 | - $current_status = ( new Booking( $id ) )->get_status(); | |
| 939 | + $current_booking = new Booking( $id ); | |
| 940 | + | |
| 941 | + // Reschedule only moves time. | |
| 942 | + if ( (int) $current_booking->get_appointment() !== $appointment ) { | |
| 943 | + return new WP_HTTP_Response( | |
| 944 | + [ | |
| 945 | + 'status_code' => 403, | |
| 946 | + 'success' => 0, | |
| 947 | + 'message' => esc_html__( 'You can not change the appointment of a booking.', 'timetics' ), | |
| 948 | + ], | |
| 949 | + 403 | |
| 950 | + ); | |
| 951 | + } | |
| 952 | + | |
| 953 | + $current_status = $current_booking->get_status(); | |
| 920 | 954 | if ( 'cancel' === $client_status ) { |
| 921 | 955 | $status = 'cancel'; |
| 922 | 956 | } else { |
| 923 | 957 | $status = $current_status; |
| @@ -1080,9 +1114,9 @@ | ||
| 1080 | 1114 | 'date' => $date, |
| 1081 | 1115 | 'end_date' => $end_date, |
| 1082 | 1116 | 'start_time' => $start_time, |
| 1083 | 1117 | 'end_time' => $end_time, |
| 1084 | - 'order_total' => $this->calculate_order_total( $data ), | |
| 1118 | + 'order_total' => ( $id && ! $is_privileged ) ? $booking->get_total() : $this->calculate_order_total( $data ), | |
| 1085 | 1119 | 'post_status' => $status, |
| 1086 | 1120 | 'location' => $location, |
| 1087 | 1121 | 'location_type' => $location_type, |
| 1088 | 1122 | 'timezone' => $timezone, |
| @@ -1088,8 +1122,12 @@ | ||
| 1088 | 1122 | 'timezone' => $timezone, |
| 1089 | 1123 | 'cancel_reason' => $cancel_reason, |
| 1090 | 1124 | ]; |
| 1091 | 1125 | |
| 1126 | + if ( 'created' === $action && '' !== $payment_method ) { | |
| 1127 | + $booking_props['payment_method'] = $payment_method; | |
| 1128 | + } | |
| 1129 | + | |
| 1092 | 1130 | $old_meeting_timestamp = 0; |
| 1093 | 1131 | |
| 1094 | 1132 | if ( $id ) { |
| 1095 | 1133 | $old_start_date = $booking->get_start_date(); |
| @@ -1143,8 +1181,21 @@ | ||
| 1143 | 1181 | /** |
| 1144 | 1182 | * Added temporary for leagacy sass. It will remove in future. |
| 1145 | 1183 | */ |
| 1146 | 1184 | do_action( 'timetics/admin/booking/after_delete_item', $booking ); |
| 1185 | + | |
| 1186 | + /** | |
| 1187 | + * Fired when an existing booking is cancelled. | |
| 1188 | + * | |
| 1189 | + * Cancel had no dedicated hook before, so integrations could | |
| 1190 | + * only react to create/reschedule/delete. | |
| 1191 | + * | |
| 1192 | + * @param int $booking_id Booking ID. | |
| 1193 | + * @param int $customer_id Customer ID. | |
| 1194 | + * @param int $meeting_id Meeting (appointment) ID. | |
| 1195 | + * @param array $data Request data. | |
| 1196 | + */ | |
| 1197 | + do_action( 'timetics_after_booking_cancel', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data ); | |
| 1147 | 1198 | } else { |
| 1148 | 1199 | // Check if the booking date/time was actually changed |
| 1149 | 1200 | $date_time_changed = ( |
| 1150 | 1201 | $old_start_date !== $start_date || |
| @@ -1182,8 +1233,22 @@ | ||
| 1182 | 1233 | $reschedule_hook_data['previous_meeting_date_timestamp'] = $old_meeting_timestamp; |
| 1183 | 1234 | } |
| 1184 | 1235 | |
| 1185 | 1236 | do_action( 'timetics_gln_hook', 'booking_rescheduled', $reschedule_hook_data ); |
| 1237 | + | |
| 1238 | + /** | |
| 1239 | + * Fired when a booking's date or time actually changed. | |
| 1240 | + * | |
| 1241 | + * `timetics_after_booking_schedule` runs on every save, so | |
| 1242 | + * it cannot tell a reschedule from an edit of the phone | |
| 1243 | + * number. This one only fires on a real time change. | |
| 1244 | + * | |
| 1245 | + * @param int $booking_id Booking ID. | |
| 1246 | + * @param int $customer_id Customer ID. | |
| 1247 | + * @param int $meeting_id Meeting (appointment) ID. | |
| 1248 | + * @param array $data Request data. | |
| 1249 | + */ | |
| 1250 | + do_action( 'timetics_after_booking_reschedule', $booking->get_id(), $customer->get_id(), $meeting->get_id(), $data ); | |
| 1186 | 1251 | } |
| 1187 | 1252 | } |
| 1188 | 1253 | } |
| 1189 | 1254 | |
| @@ -1232,9 +1297,20 @@ | ||
| 1232 | 1297 | // For newly created bookings, create the calendar event now that the |
| 1233 | 1298 | // booking schedule entry exists. This generates the Google Meet link |
| 1234 | 1299 | // (stored in booking meta) so it can be shown on the success page and |
| 1235 | 1300 | // included in the notification emails sent below. |
| 1236 | - if ( 'created' === $action && 'cancel' !== $status ) { | |
| 1301 | + // | |
| 1302 | + // Skipped while an online gateway payment is still outstanding — the | |
| 1303 | + // real event gets created once payment confirms, in make_payment() and | |
| 1304 | + // Hooks::update_booking_payment_status(). Based on payment_method and | |
| 1305 | + // amount alone, NOT $status: a privileged (logged-in admin/staff) user | |
| 1306 | + // gets $default_status regardless of gateway, which can be 'approved' | |
| 1307 | + // even though no payment happened yet — checking $status here would | |
| 1308 | + // miss that and create the event before the customer actually pays. | |
| 1309 | + $is_awaiting_online_payment = 'created' === $action && $server_total > 0 | |
| 1310 | + && in_array( $payment_method_l, [ 'stripe', 'woocommerce', 'paypal' ], true ); | |
| 1311 | + | |
| 1312 | + if ( 'created' === $action && 'cancel' !== $status && ! $is_awaiting_online_payment ) { | |
| 1237 | 1313 | $booking->create_event(); |
| 1238 | 1314 | } |
| 1239 | 1315 | |
| 1240 | 1316 | // Send booking creation emails for new bookings not processed through |
| @@ -1391,8 +1467,23 @@ | ||
| 1391 | 1467 | |
| 1392 | 1468 | $booking->release_slot(); |
| 1393 | 1469 | |
| 1394 | 1470 | $recurrences = $booking->get_recurrence(); |
| 1471 | + | |
| 1472 | + /** | |
| 1473 | + * Fired before a booking is deleted, while its data can still be read. | |
| 1474 | + * | |
| 1475 | + * `timetics_after_booking_delete` runs after the post has already gone | |
| 1476 | + * and only receives the recurrence data, so an integration that needs | |
| 1477 | + * the booking, customer or meeting has to listen here instead. | |
| 1478 | + * | |
| 1479 | + * @param int $booking_id Booking ID. | |
| 1480 | + * @param int $customer_id Customer ID. | |
| 1481 | + * @param int $meeting_id Meeting (appointment) ID. | |
| 1482 | + * @param array $data Request data. | |
| 1483 | + */ | |
| 1484 | + do_action( 'timetics_before_booking_delete', $booking->get_id(), $booking->get_customer_id(), $meeting->get_id(), [] ); | |
| 1485 | + | |
| 1395 | 1486 | $booking->delete_event(); |
| 1396 | 1487 | $booking->delete(); |
| 1397 | 1488 | |
| 1398 | 1489 | $is_email_to_customer = timetics_get_option( 'booking_canceled_customer'); |
| @@ -1780,8 +1871,23 @@ | ||
| 1780 | 1871 | 409 |
| 1781 | 1872 | ); |
| 1782 | 1873 | } |
| 1783 | 1874 | |
| 1875 | + // A previous decline released this booking's slot. Bind runs before the card | |
| 1876 | + // is charged, so it is the last safe point to take the slot back — refusing | |
| 1877 | + // here costs the customer nothing, refusing after payment would take their | |
| 1878 | + // money for a time somebody else now holds. | |
| 1879 | + if ( ! $booking->reserve_slot() ) { | |
| 1880 | + return new WP_HTTP_Response( | |
| 1881 | + [ | |
| 1882 | + 'success' => 0, | |
| 1883 | + 'status_code' => 409, | |
| 1884 | + 'message' => esc_html__( 'This time slot is no longer available. Please pick another time.', 'timetics' ), | |
| 1885 | + ], | |
| 1886 | + 409 | |
| 1887 | + ); | |
| 1888 | + } | |
| 1889 | + | |
| 1784 | 1890 | $result = $stripe->update_payment_intent( |
| 1785 | 1891 | $intent_id, |
| 1786 | 1892 | [ |
| 1787 | 1893 | 'booking_id' => $booking_id, |
| @@ -1799,8 +1905,12 @@ | ||
| 1799 | 1905 | 502 |
| 1800 | 1906 | ); |
| 1801 | 1907 | } |
| 1802 | 1908 | |
| 1909 | + // Record the intent id now (not just at make_payment finalize) so the | |
| 1910 | + // unpaid-booking cleanup sweep can check Stripe before cancelling. | |
| 1911 | + $booking->set_stripe_payment_intent_id( $intent_id ); | |
| 1912 | + | |
| 1803 | 1913 | return new WP_HTTP_Response( |
| 1804 | 1914 | [ |
| 1805 | 1915 | 'success' => 1, |
| 1806 | 1916 | 'status_code' => 200, |
| @@ -1834,9 +1944,12 @@ | ||
| 1834 | 1944 | // constant-time comparison |
| 1835 | 1945 | if ( ! hash_equals( $stored_token, $appointment_token ) ) { |
| 1836 | 1946 | return false; |
| 1837 | 1947 | } |
| 1838 | - if ( 'pending' !== (string) $booking->get_status() ) { | |
| 1948 | + // A declined card leaves the booking 'failed' and the customer retries on that | |
| 1949 | + // same booking, so 'failed' has to pass too. Anything further along | |
| 1950 | + // ( approved / completed / cancelled ) is finished and must never be payable. | |
| 1951 | + if ( ! in_array( (string) $booking->get_status(), [ 'pending', 'failed' ], true ) ) { | |
| 1839 | 1952 | return false; |
| 1840 | 1953 | } |
| 1841 | 1954 | |
| 1842 | 1955 | return true; |