[Back] <?php
/*
* Copyright (c) 2024 LatePoint LLC. All rights reserved.
*/
class OsOrderIntentModel extends OsModel {
var $id,
$intent_key,
$customer_id,
$booking_form_page_url,
$cart_items_data,
$restrictions_data,
$presets_data,
$payment_data = '',
$payment_data_arr,
$other_data,
$charge_amount,
$specs_charge_amount,
$coupon_code,
$coupon_discount,
$total,
$subtotal,
$price_breakdown,
$order_id,
$tax_total,
$status,
$updated_at,
$created_at;
function __construct( $id = false ) {
parent::__construct();
$this->table_name = LATEPOINT_TABLE_ORDER_INTENTS;
if ( $id ) {
$this->load_by_id( $id );
}
}
protected function params_to_sanitize() {
return [
'charge_amount' => 'money',
'total' => 'money',
'subtotal' => 'money',
'tax_total' => 'money',
];
}
public function delete_meta_by_key( $meta_key ) {
if ( $this->is_new_record() ) {
return false;
}
$meta = new OsOrderIntentMetaModel();
return $meta->delete_by_key( $meta_key, $this->id );
}
public function get_meta_by_key( $meta_key, $default = false ) {
if ( $this->is_new_record() ) {
return $default;
}
$meta = new OsOrderIntentMetaModel();
return $meta->get_by_key( $meta_key, $this->id, $default );
}
public function save_meta_by_key( $meta_key, $meta_value ) {
if ( $this->is_new_record() ) {
return false;
}
$meta = new OsOrderIntentMetaModel();
return $meta->save_by_key( $meta_key, $meta_value, $this->id );
}
public function get_customer() {
if ( $this->customer_id ) {
if ( ! isset( $this->customer ) || ( isset( $this->customer ) && ( $this->customer->id != $this->customer_id ) ) ) {
$this->customer = new OsCustomerModel( $this->customer_id );
}
} else {
$this->customer = new OsCustomerModel();
}
return $this->customer;
}
public function build_cart_object(): OsCartModel {
$cart = new OsCartModel();
$cart->total = $this->total;
$cart->subtotal = $this->subtotal;
$cart->coupon_code = $this->coupon_code;
$cart->coupon_discount = $this->coupon_discount;
$cart->tax_total = $this->tax_total;
// add items from intent
$intent_cart_items = json_decode( $this->cart_items_data, true );
foreach ( $intent_cart_items as $cart_item_data ) {
$cart->add_item( OsCartsHelper::create_cart_item_from_item_data( $cart_item_data ), false );
}
// restore payment info
$payment_data = json_decode( $this->payment_data, true );
$cart->payment_method = $payment_data['method'];
$cart->payment_time = $payment_data['time'];
$cart->payment_portion = $payment_data['portion'];
$cart->payment_token = $payment_data['token'];
$cart->payment_processor = $payment_data['processor'];
return $cart;
}
public function get_payment_data_value( string $key ): string {
if ( ! isset( $this->payment_data_arr ) ) {
$this->payment_data_arr = json_decode( $this->payment_data, true );
}
return $this->payment_data_arr[ $key ] ?? '';
}
public function set_payment_data_value( string $key, string $value, bool $save = true ) {
$this->payment_data_arr = json_decode( $this->payment_data, true );
$this->payment_data_arr[ $key ] = $value;
$this->payment_data = wp_json_encode( $this->payment_data_arr );
if ( $save ) {
$this->update_attributes( [ 'payment_data' => $this->payment_data ] );
}
}
public function is_bookable( array $settings = []): bool {
$cart = $this->build_cart_object();
// loop items and check if bookings are still available
foreach ( $cart->get_items() as $cart_item ) {
switch ( $cart_item->variant ) {
case LATEPOINT_ITEM_VARIANT_BOOKING:
$booking = $cart_item->build_original_object_from_item_data();
if ( ! $booking->is_bookable($settings) ) {
$this->add_error( 'send_to_step', $booking->get_error_messages(), 'booking__datepicker' );
return false;
}
break;
case LATEPOINT_ITEM_VARIANT_BUNDLE:
break;
}
}
return true;
}
public function is_processing(): bool {
return $this->status == LATEPOINT_ORDER_INTENT_STATUS_PROCESSING;
}
public function is_failed(): bool {
return $this->status == LATEPOINT_ORDER_INTENT_STATUS_FAILED;
}
public function mark_as_failed() {
$this->update_attributes( [ 'status' => LATEPOINT_ORDER_INTENT_STATUS_FAILED ] );
/**
* Order intent is marked as failed
*
* @param {OsOrderIntentModel} $order_intent Instance of order intent model that has failed
*
* @since 5.2.0
* @hook latepoint_order_intent_failed
*
*/
do_action( 'latepoint_order_intent_failed', $this );
}
public function wait_for_transaction_completion() : OsOrderIntentModel {
$attempts = 0;
$max_attempts = 6;
$delay_seconds = 2;
while ($attempts < $max_attempts) {
if (!$this->is_processing()) {
return $this;
}
sleep($delay_seconds);
$attempts++;
$this->load_by_id($this->id);
}
if($this->is_processing()){
// if it's still processing after waiting - mark as failed
$this->mark_as_failed();
}
return $this;
}
public function convert_to_order() {
if($this->is_converted()){
return $this->order_id;
}
if($this->is_processing()){
$this->wait_for_transaction_completion();
if($this->is_failed()){
$this->add_error( 'transaction_intent_error', __('Can not convert to transaction, because transaction intent conversion is being processed', 'latepoint') );
return false;
}else if($this->is_converted()){
return $this->order_id;
}
}
$this->mark_as_processing();
try {
// process is cart -> order intent -> order
if ( ! $this->is_bookable() ) {
$this->mark_as_new();
return false;
}
// process payment if there is amount due
$transaction = OsPaymentsHelper::process_payment_for_order_intent( $this );
// payment processing can take a while, make sure to check if the intent wasn't converted already in the meantime
$converted_order_id = OsOrderIntentHelper::is_converted( $this->id );
if ( $converted_order_id ) {
$order = new OsOrderModel($converted_order_id);
$this->mark_as_converted($order);
return $converted_order_id;
}
if ( $this->get_error() ) {
OsDebugHelper::log( 'Error converting intent to an order', 'order_error', $this->get_error_messages() );
$this->mark_as_new();
return false;
}
$cart_from_intent = $this->build_cart_object();
$order = new OsOrderModel();
$order->customer_id = $this->customer_id;
$order->total = $this->total;
$order->subtotal = $this->subtotal;
$order->coupon_code = $this->coupon_code;
$order->coupon_discount = $this->coupon_discount;
$order->tax_total = $this->tax_total;
$order->source_url = $this->booking_form_page_url;
$order->customer_comment = $this->customer->notes;
$order_initial_payment_data_arr = json_decode( $this->payment_data, true );
$order_initial_payment_data_arr['charge_amount'] = $this->charge_amount;
$order->initial_payment_data = wp_json_encode($order_initial_payment_data_arr);
// order's price breakdown should only hold cart items, and never holds total, subtotal, balance variables because those are stored on order model itself and/or generated on the fly
$order->price_breakdown = wp_json_encode( $cart_from_intent->generate_price_breakdown_rows(['balance', 'total', 'subtotal']));
/**
* Filters order right before it's about to be saved when converting from an order intent
*
* @param {OsOrderModel} $order Order to be filtered
* @returns {OsOrderModel} The filtered order
*
* @since 5.0.0
* @hook latepoint_before_order_save_from_order_intent
*
*/
$order = apply_filters( 'latepoint_before_order_save_from_order_intent', $order );
if ( $order->save() ) {
$this->mark_as_converted( $order );
OsInvoicesHelper::create_invoices_for_new_order($order);
foreach ( $cart_from_intent->get_items() as $cart_item ) {
$order_item = OsOrdersHelper::create_order_item_from_cart_item( $cart_item );
$order_item->order_id = $order->id;
$order_item->save();
}
if ( $transaction ) {
$transaction->order_id = $order->id;
$invoice = OsInvoicesHelper::get_matching_invoice_for_transaction($transaction);
if(!$invoice->is_new_record()) $transaction->invoice_id = $invoice->id;
if ( $transaction->save() ) {
/**
* Transaction was created
*
* @param {OsTransactionModel} $transaction instance of transaction model that was created
*
* @since 5.1.0
* @hook latepoint_transaction_created
*
*/
do_action( 'latepoint_transaction_created', $transaction );
if(!$invoice->is_new_record()){
$old_invoice = clone $invoice;
$invoice->update_attributes(['status' => LATEPOINT_INVOICE_STATUS_PAID]);
/**
* Invoice was updated
*
* @param {OsInvoiceModel} $invoice instance of invoice model after it was updated
* @param {OsInvoiceModel} $old_invoice instance of invoice model before it was updated
*
* @since 5.1.0
* @hook latepoint_invoice_updated
*
*/
do_action( 'latepoint_invoice_updated', $invoice, $old_invoice );
// update other invoices with this paid amount, for example if we charge a deposit - then this transaction should also be reflected in draft invoices for the remaining amount that were created earlier
$other_invoices = new OsInvoiceModel();
$other_invoices = $other_invoices->where(['status' => LATEPOINT_INVOICE_STATUS_DRAFT, 'order_id' => $order->id])->get_results_as_models();
if($other_invoices){
foreach($other_invoices as $invoice){
$data = json_decode($invoice->data, true);
$data['totals']['payments'] = $order->get_total_amount_paid_from_transactions(true);
$invoice->update_attributes(['data' => json_encode($data)]);
}
}
}
} else {
OsDebugHelper::log( 'Error creating transaction', 'transaction_error', $transaction->get_error_messages() );
}
}
$order_bookings = $order->get_bookings_from_order_items(true);
if ( $order_bookings ) {
foreach ( $order_bookings as $order_item_id => $order_booking ) {
$order_booking->order_item_id = $order_item_id;
$order_booking->customer_id = $order->customer_id;
$order_booking->end_time = $order_booking->calculate_end_time();
$order_booking->end_date = $order_booking->calculate_end_date();
$order_booking->set_utc_datetimes();
$service = new OsServiceModel( $order_booking->service_id );
$order_booking->buffer_before = $service->buffer_before;
$order_booking->buffer_after = $service->buffer_after;
$order_booking->customer_comment = $order->customer->notes;
if ( $order_booking->save() ) {
/**
* Booking was created
*
* @param {OsBookingModel} $booking instance of booking model that was created
*
* @since 5.0.0
* @hook latepoint_booking_created
*
*/
do_action( 'latepoint_booking_created', $order_booking );
// set booking id to the one that was created for item data property
$order_item = new OsOrderItemModel( $order_item_id );
$item_data = json_decode( $order_item->item_data, true );
$item_data['id'] = $order_booking->id;
$order_item->update_attributes( [ 'item_data' => wp_json_encode( $item_data ) ] );
} else {
OsDebugHelper::log( 'Unable to save booking', 'booking_save_error', $order_booking->get_error_messages() );
}
}
}
// update connected cart with created order id
$this->mark_cart_converted();
$order->determine_payment_status();
// update with latest info
$order->get_items(true);
/**
* Order was created
*
* @param {OsOrderModel} $order instance of order model that was created
*
* @since 5.0.0
* @hook latepoint_order_created
*
*/
do_action( 'latepoint_order_created', $order );
return $order->id;
} else {
$this->add_error( 'order_error', $order->get_error_messages() );
$this->mark_as_new();
return false;
}
} catch ( Exception $e ) {
$this->mark_as_new();
// translators: %s is the error description
$this->add_error( 'order_error', sprintf(__('Error: %s', 'latepoint'), $e->getMessage() ));
OsDebugHelper::log( 'Error converting intent to an order', 'order_error', $e->getMessage() );
return false;
}
}
public function get_by_intent_key( $intent_key ) {
return $this->where( [ 'intent_key' => $intent_key ] )->set_limit( 1 )->get_results_as_models();
}
public function mark_as_converted( OsOrderModel $order ) {
if ( empty( $order->id ) ) {
return false;
}
$this->update_attributes( [ 'order_id' => $order->id, 'status' => LATEPOINT_ORDER_INTENT_STATUS_CONVERTED ] );
/**
* Order intent is converted to order
*
* @param {OsOrderIntentModel} $order_intent Instance of order intent model that has been converted to order
* @param {OsOrderModel} $order Instance of order model that order intent was converted to
*
* @since 5.0.0
* @hook latepoint_order_intent_converted
*
*/
do_action( 'latepoint_order_intent_converted', $this, $order );
}
public function mark_as_processing() {
$this->update_attributes( [ 'status' => LATEPOINT_ORDER_INTENT_STATUS_PROCESSING ] );
/**
* Order intent is marked as processing
*
* @param {OsOrderIntentModel} $order_intent Instance of order intent model that has started processing
*
* @since 5.0.0
* @hook latepoint_order_intent_processing
*
*/
do_action( 'latepoint_order_intent_processing', $this );
}
public function mark_as_new() {
$this->update_attributes( [ 'status' => LATEPOINT_ORDER_INTENT_STATUS_NEW ] );
/**
* Order intent is marked as new
*
* @param {OsOrderIntentModel} $order_intent Instance of order intent model that is being marked as new
*
* @since 5.0.0
* @hook latepoint_order_intent_new
*
*/
do_action( 'latepoint_order_intent_new', $this );
}
// Determines if order intent has been converted into a order already
public function is_converted() : bool {
if ( empty( $this->order_id ) ) {
return false;
} else {
return true;
}
}
public function generate_data_vars(): array {
$vars = [
'id' => $this->id,
'intent_key' => $this->intent_key,
'customer_id' => $this->customer_id,
'booking_form_page_url' => $this->booking_form_page_url,
'cart_items_data' => !empty($this->cart_items_data) ? json_decode( $this->cart_items_data, true ) : [],
'restrictions_data' => !empty($this->restrictions_data) ? json_decode( $this->restrictions_data, true ) : [],
'presets_data' => !empty($this->presets_data) ? json_decode( $this->presets_data, true ) : [],
'payment_data' => !empty($this->payment_data) ? json_decode( $this->payment_data, true ) : [],
'other_data' => !empty($this->other_data) ? json_decode( $this->other_data, true ) : [],
'order_id' => $this->order_id,
'coupon_code' => $this->coupon_code,
'coupon_discount' => $this->coupon_discount,
'tax_total' => $this->tax_total,
'updated_at' => $this->updated_at,
'created_at' => $this->created_at,
];
return $vars;
}
public function get_page_url_with_intent() {
$booking_page_url = $this->booking_form_page_url;
$existing_var_position = strpos( $booking_page_url, 'latepoint_order_intent_key=' );
if ( $existing_var_position === false ) {
// no intent variable in url
$question_position = strpos( $booking_page_url, '?' );
if ( $question_position === false ) {
// no ?query params
$hash_position = strpos( $booking_page_url, '#' );
if ( $hash_position === false ) {
// no hashtag in url
$booking_page_url = $booking_page_url . '?latepoint_order_intent_key=' . $this->intent_key;
} else {
// hashtag in url and no ?query, prepend the hashtag with query
$booking_page_url = substr_replace( $booking_page_url, '?latepoint_order_intent_key=' . $this->intent_key . '#', $hash_position, 1 );
}
} else {
// ?query string exists, add intent key to it
$booking_page_url = substr_replace( $booking_page_url, '?latepoint_order_intent_key=' . $this->intent_key . '&', $question_position, 1 );
}
} else {
// intent key variable exist in url
preg_match( '/latepoint_order_intent_key=([\d,\w]*)/', $booking_page_url, $matches );
if ( isset( $matches[1] ) ) {
$booking_page_url = str_replace( 'latepoint_order_intent_key=' . $matches[1], 'latepoint_order_intent_key=' . $this->intent_key, $booking_page_url );
}
}
return $booking_page_url;
}
protected function before_create() {
if ( empty( $this->intent_key ) ) {
$this->intent_key = bin2hex( openssl_random_pseudo_bytes( 10 ) );
}
if ( empty( $this->status ) ) {
$this->status = LATEPOINT_ORDER_INTENT_STATUS_NEW;
}
}
protected function allowed_params( $role = 'admin' ) {
$allowed_params = array(
'customer_id',
'cart_items_data',
'restrictions_data',
'presets_data',
'payment_data',
'other_data',
'booking_form_page_url',
'intent_key',
'order_id',
'coupon_code',
'coupon_discount',
'tax_total',
'status',
);
return $allowed_params;
}
protected function params_to_save( $role = 'admin' ) {
$params_to_save = array(
'customer_id',
'cart_items_data',
'restrictions_data',
'presets_data',
'payment_data',
'other_data',
'booking_form_page_url',
'intent_key',
'total',
'subtotal',
'charge_amount',
'specs_charge_amount',
'price_breakdown',
'order_id',
'coupon_code',
'coupon_discount',
'tax_total',
'status',
);
return $params_to_save;
}
protected function properties_to_validate() {
$validations = array(
'customer_id' => array( 'presence' ),
);
return $validations;
}
public function mark_cart_converted(?OsCartModel $cart = null) : bool {
if($this->is_new_record() || empty($this->order_id)){
return false;
}
if(!empty($cart)){
$cart->order_id = $this->order_id;
$cart->save();
}else{
$carts = new OsCartModel();
$carts = $carts->where( [ 'order_intent_id' => $this->id ] )->get_results_as_models();
if ( ! empty( $carts ) ) {
foreach ( $carts as $cart ) {
$cart->order_id = $this->order_id;
$cart->save();
}
}
}
return true;
}
}