PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.64
Timetics – Appointment Booking Calendar & Scheduling v1.0.64
1.0.64 1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.19 1.0.2 1.0.20 1.0.21 All 65 releases
← All changes | core/bookings/api-booking.php +122 -9 1.0.62 → 1.0.64 View file →
@@ -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;