authorization_endpoint = self::AUTHORIZATION_ENDPOINT;
$this->token_endpoint = self::TOKEN_ENDPOINT;
$this->refresh_token_endpoint = self::TOKEN_ENDPOINT;
add_action( 'rest_api_init', array( $this, 'register_webhook_route' ) );
add_filter( 'adfoin_action_providers', array( $this, 'register_actions' ), 10, 1 );
add_filter( 'adfoin_settings_tabs', array( $this, 'register_settings_tab' ), 10, 1 );
add_action( 'adfoin_settings_view', array( $this, 'render_settings' ), 10, 1 );
add_action( 'adfoin_action_fields', array( $this, 'render_action_template' ), 10, 1 );
add_action( 'wp_ajax_adfoin_get_quickbooksonline_credentials', array( $this, 'get_credentials' ) );
add_action( 'wp_ajax_adfoin_save_quickbooksonline_credentials', array( $this, 'save_credentials' ) );
add_action( 'wp_ajax_adfoin_get_quickbooksonline_fields', array( $this, 'ajax_get_fields' ) );
add_action( 'wp_ajax_adfoin_get_quickbooksonline_items', array( $this, 'ajax_get_items' ) );
add_action( 'adfoin_quickbooksonline_job_queue', array( $this, 'handle_job_queue' ), 10, 1 );
}
public function register_actions( $actions ) {
$actions['quickbooksonline'] = array(
'title' => __( 'QuickBooks Online', 'advanced-form-integration' ),
'tasks' => array(
'create_customer' => __( 'Create / Update Customer', 'advanced-form-integration' ),
'create_invoice' => __( 'Create Invoice', 'advanced-form-integration' ),
),
);
return $actions;
}
public function register_settings_tab( $tabs ) {
$tabs['quickbooksonline'] = __( 'QuickBooks Online', 'advanced-form-integration' );
return $tabs;
}
public function render_settings( $current_tab ) {
if ( 'quickbooksonline' !== $current_tab ) {
return;
}
if ( ! class_exists( 'ADFOIN_OAuth_Manager' ) ) {
require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php';
}
$redirect_uri = $this->get_redirect_uri();
$fields = array(
array(
'name' => 'client_id',
'label' => __( 'Client ID', 'advanced-form-integration' ),
'type' => 'text',
'required' => true,
'mask' => true,
'show_in_table' => true,
),
array(
'name' => 'client_secret',
'label' => __( 'Client Secret', 'advanced-form-integration' ),
'type' => 'text',
'required' => true,
'mask' => true,
'show_in_table' => true,
),
array(
'name' => 'environment',
'label' => __( 'Environment', 'advanced-form-integration' ),
'type' => 'select',
'required' => true,
'options' => array(
'production' => __( 'Production', 'advanced-form-integration' ),
'sandbox' => __( 'Sandbox', 'advanced-form-integration' ),
),
'description' => __( 'Use Sandbox while testing — switch to Production for live data.', 'advanced-form-integration' ),
),
);
$instructions = '
';
$instructions .= '- ' . sprintf( __( 'Sign in to %s and create a new app.', 'advanced-form-integration' ), 'Intuit Developer Dashboard' ) . '
';
$instructions .= '- ' . __( 'Under the app, open "Keys & OAuth" and copy the Client ID + Secret for the environment you want (Production or Sandbox).', 'advanced-form-integration' ) . '
';
$instructions .= '- ' . __( 'Add this redirect URI to the app settings:', 'advanced-form-integration' ) . '
';
$instructions .= '' . esc_html( $redirect_uri ) . ' ';
$instructions .= '- ' . sprintf( __( 'Make sure the app requests the %s scope.', 'advanced-form-integration' ), '
com.intuit.quickbooks.accounting' ) . ' ';
$instructions .= '- ' . __( 'Paste your Client ID + Secret here, pick the environment, then click Save & Authorize.', 'advanced-form-integration' ) . '
';
$instructions .= '
';
$config = array(
'show_status' => true,
'modal_title' => __( 'Connect QuickBooks Online', 'advanced-form-integration' ),
'submit_text' => __( 'Save & Authorize', 'advanced-form-integration' ),
);
ADFOIN_OAuth_Manager::render_oauth_settings_view( 'quickbooksonline', __( 'QuickBooks Online', 'advanced-form-integration' ), $fields, $instructions, $config );
}
public function register_webhook_route() {
register_rest_route(
'advancedformintegration',
'/quickbooksonline',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_oauth_callback' ),
'permission_callback' => '__return_true',
)
);
}
/**
* Intuit returns `code`, `state`, and `realmId` as query params.
* We persist realmId on the credential record so every subsequent
* API call has the right company prefix.
*/
public function handle_oauth_callback( $request ) {
$params = $request->get_params();
$code = isset( $params['code'] ) ? sanitize_text_field( $params['code'] ) : '';
$state = isset( $params['state'] ) ? sanitize_text_field( $params['state'] ) : '';
$realm_id = isset( $params['realmId'] ) ? sanitize_text_field( $params['realmId'] ) : '';
$context = self::consume_oauth_state( $state, 'quickbooksonline' );
$cred_id = $context ? $context['cred_id'] : '';
if ( ! $code || ! $cred_id ) {
require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php';
ADFOIN_OAuth_Manager::handle_callback_close_popup( false, __( 'Authorization failed — missing code or state.', 'advanced-form-integration' ) );
exit;
}
$this->set_credentials_by_id( $cred_id );
$this->realm_id = $realm_id;
$response = $this->request_token( $code );
$success = false;
$message = 'Unknown error';
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['access_token'] ) ) {
$success = true;
$message = __( 'Connected successfully!', 'advanced-form-integration' );
} else {
$message = isset( $body['error_description'] ) ? $body['error_description'] : __( 'Token exchange failed.', 'advanced-form-integration' );
}
} else {
$message = is_wp_error( $response ) ? $response->get_error_message() : __( 'HTTP error during token exchange.', 'advanced-form-integration' );
}
require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php';
ADFOIN_OAuth_Manager::handle_callback_close_popup( $success, $message );
exit;
}
/**
* QBO's token endpoint expects HTTP Basic auth (client_id:client_secret
* base64-encoded) — NOT client credentials in the body like Moneybird.
* This is the single most common stumbling block when wiring Intuit
* OAuth from scratch.
*/
protected function request_token( $code ) {
$args = array(
'timeout' => 30,
'headers' => array(
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'Basic ' . base64_encode( $this->client_id . ':' . $this->client_secret ),
),
'body' => http_build_query(
array(
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->get_redirect_uri(),
),
'',
'&'
),
);
$response = wp_remote_post( $this->token_endpoint, $args );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$this->apply_token_response( is_array( $body ) ? $body : array() );
$this->save_data();
return $response;
}
protected function refresh_token() {
if ( ! $this->refresh_token ) {
return new WP_Error( 'quickbooksonline_missing_refresh_token', __( 'QuickBooks refresh token is missing.', 'advanced-form-integration' ) );
}
$args = array(
'timeout' => 30,
'headers' => array(
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => 'Basic ' . base64_encode( $this->client_id . ':' . $this->client_secret ),
),
'body' => http_build_query(
array(
'grant_type' => 'refresh_token',
'refresh_token' => $this->refresh_token,
),
'',
'&'
),
);
$response = wp_remote_post( $this->refresh_token_endpoint, $args );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$this->apply_token_response( is_array( $body ) ? $body : array() );
$this->save_data();
return $response;
}
/**
* AJAX: save credentials, then return the auth URL for the popup to
* navigate to. State binds the popup callback back to this record so
* tokens land on the right credential.
*/
public function save_credentials() {
adfoin_require_manage_options();
if ( ! wp_verify_nonce( isset( $_POST['_nonce'] ) ? $_POST['_nonce'] : '', 'advanced-form-integration' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) );
}
$platform = 'quickbooksonline';
$credentials = adfoin_read_credentials( $platform );
if ( ! is_array( $credentials ) ) {
$credentials = array();
}
if ( isset( $_POST['delete_index'] ) ) {
$index = intval( wp_unslash( $_POST['delete_index'] ) );
if ( isset( $credentials[ $index ] ) ) {
array_splice( $credentials, $index, 1 );
adfoin_save_credentials( $platform, $credentials );
wp_send_json_success( array( 'message' => 'Deleted' ) );
}
wp_send_json_error( __( 'Invalid index', 'advanced-form-integration' ) );
}
$id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
$title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
$client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : '';
$environment = isset( $_POST['environment'] ) ? sanitize_text_field( wp_unslash( $_POST['environment'] ) ) : 'production';
if ( ! in_array( $environment, array( 'production', 'sandbox' ), true ) ) {
$environment = 'production';
}
if ( empty( $id ) ) {
$id = wp_generate_uuid4();
}
$new_data = array(
'id' => $id,
'title' => $title,
'client_id' => $client_id,
'client_secret' => $client_secret,
'environment' => $environment,
'realm_id' => '',
'access_token' => '',
'refresh_token' => '',
'expires_at' => 0,
);
$found = false;
foreach ( $credentials as &$cred ) {
if ( isset( $cred['id'] ) && $cred['id'] === $id ) {
// Preserve tokens + realm_id when only the title changes.
if ( isset( $cred['client_id'] ) && $cred['client_id'] === $client_id
&& isset( $cred['client_secret'] ) && $cred['client_secret'] === $client_secret
&& isset( $cred['environment'] ) && $cred['environment'] === $environment ) {
$new_data['realm_id'] = isset( $cred['realm_id'] ) ? $cred['realm_id'] : '';
$new_data['access_token'] = isset( $cred['access_token'] ) ? $cred['access_token'] : '';
$new_data['refresh_token'] = isset( $cred['refresh_token'] ) ? $cred['refresh_token'] : '';
$new_data['expires_at'] = isset( $cred['expires_at'] ) ? $cred['expires_at'] : 0;
}
$cred = $new_data;
$found = true;
break;
}
}
unset( $cred );
if ( ! $found ) {
$credentials[] = $new_data;
}
adfoin_save_credentials( $platform, $credentials );
$auth_url = add_query_arg(
array(
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $this->get_redirect_uri(),
'scope' => self::DEFAULT_SCOPE,
'state' => self::issue_oauth_state( 'quickbooksonline', $id ),
),
$this->authorization_endpoint
);
wp_send_json_success( array( 'auth_url' => $auth_url ) );
}
public function get_credentials() {
adfoin_require_manage_options();
if ( ! wp_verify_nonce( isset( $_POST['_nonce'] ) ? $_POST['_nonce'] : '', 'advanced-form-integration' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) );
}
wp_send_json_success( $this->safe_credentials_list() );
}
public function set_credentials_by_id( $cred_id ) {
if ( ! class_exists( 'ADFOIN_OAuth_Manager' ) ) {
require_once plugin_dir_path( __FILE__ ) . '../../includes/class-adfoin-oauth-manager.php';
}
$credentials = ADFOIN_OAuth_Manager::get_credentials_by_id( 'quickbooksonline', $cred_id );
if ( ! $credentials ) {
return;
}
$this->cred_id = $cred_id;
$this->client_id = isset( $credentials['client_id'] ) ? $credentials['client_id'] : '';
$this->client_secret = isset( $credentials['client_secret'] ) ? $credentials['client_secret'] : '';
$this->access_token = isset( $credentials['access_token'] ) ? $credentials['access_token'] : '';
$this->refresh_token = isset( $credentials['refresh_token'] ) ? $credentials['refresh_token'] : '';
$this->expires_at = isset( $credentials['expires_at'] ) ? (int) $credentials['expires_at'] : 0;
$this->realm_id = isset( $credentials['realm_id'] ) ? $credentials['realm_id'] : '';
$this->environment = isset( $credentials['environment'] ) ? $credentials['environment'] : 'production';
}
protected function get_redirect_uri() {
return site_url( '/wp-json/advancedformintegration/quickbooksonline' );
}
protected function save_data() {
// realm_id was captured on the OAuth callback BEFORE
// request_token ran — persist it alongside the tokens.
if ( ! empty( $this->cred_id ) ) {
$this->persist_token_to_credential( array( 'realm_id' => $this->realm_id ) );
}
}
protected function reset_data() {
$this->access_token = '';
$this->refresh_token = '';
$this->expires_at = 0;
$this->save_data();
}
/**
* Return the correct API base for the chosen environment plus
* the realm_id prefix. Every QBO endpoint hangs off this.
*/
protected function api_base() {
$base = ( 'sandbox' === $this->environment ) ? self::API_BASE_SANDBOX : self::API_BASE_PRODUCTION;
return $base . rawurlencode( $this->realm_id ) . '/';
}
/**
* Public so the Pro add-on can reuse the same authenticated
* transport without duplicating refresh / 401-retry logic.
*/
public function qbo_request( $endpoint, $method = 'POST', $body = array(), $record = array(), $query_args = array() ) {
if ( ! $this->access_token ) {
return new WP_Error( 'quickbooksonline_missing_token', __( 'QuickBooks access token is missing. Re-authorize the account.', 'advanced-form-integration' ) );
}
if ( ! $this->realm_id ) {
return new WP_Error( 'quickbooksonline_missing_realm', __( 'QuickBooks realmId (company) is missing. Re-authorize the account.', 'advanced-form-integration' ) );
}
// Proactive refresh on cached expiry. is_token_expired() returns
// false when expires_at is unknown (the reactive 401 path
// below handles that case).
if ( $this->is_token_expired() ) {
$this->refresh_token();
}
$query_args = array_merge( array( 'minorversion' => self::MINOR_VERSION ), $query_args );
$url = $this->api_base() . ltrim( $endpoint, '/' );
$url = add_query_arg( $query_args, $url );
$args = array(
'timeout' => 30,
'method' => strtoupper( $method ),
'headers' => array(
'Authorization' => 'Bearer ' . $this->access_token,
'Accept' => 'application/json',
),
);
if ( in_array( $args['method'], array( 'POST', 'PATCH', 'PUT' ), true ) ) {
$args['headers']['Content-Type'] = 'application/json';
if ( ! empty( $body ) ) {
$args['body'] = wp_json_encode( $body );
}
}
$response = wp_remote_request( esc_url_raw( $url ), $args );
$status = (int) wp_remote_retrieve_response_code( $response );
// Reactive 401: refresh once, then retry.
if ( 401 === $status ) {
$refresh = $this->refresh_token();
if ( ! is_wp_error( $refresh ) && $this->access_token ) {
$args['headers']['Authorization'] = 'Bearer ' . $this->access_token;
$response = wp_remote_request( esc_url_raw( $url ), $args );
$status = (int) wp_remote_retrieve_response_code( $response );
}
}
if ( $record ) {
adfoin_add_to_log( $response, $url, $args, $record );
}
if ( is_wp_error( $response ) ) {
return $response;
}
if ( $status >= 400 ) {
return new WP_Error( 'quickbooksonline_http_error', wp_remote_retrieve_body( $response ), array( 'status' => $status ) );
}
return $response;
}
public function render_action_template() {
?>
set_credentials_by_id( $cred_id );
}
$fields = ( 'create_invoice' === $task ) ? $this->get_invoice_fields() : $this->get_customer_fields();
wp_send_json_success( $fields );
}
/**
* AJAX: list Items (products/services) on the company file. The Pro
* Vue can use this to populate an Item picker, but the free template
* just exposes the raw ItemRef ID via the JSON invoice-lines field.
*/
public function ajax_get_items() {
adfoin_verify_nonce();
$cred_id = isset( $_POST['credId'] ) ? sanitize_text_field( wp_unslash( $_POST['credId'] ) ) : '';
if ( ! $cred_id ) {
wp_send_json_error( array( 'message' => __( 'Missing credential id.', 'advanced-form-integration' ) ) );
}
$this->set_credentials_by_id( $cred_id );
$response = $this->qbo_request(
"query",
'GET',
array(),
array(),
array( 'query' => 'select Id, Name from Item where Active = true MAXRESULTS 500' )
);
if ( is_wp_error( $response ) ) {
wp_send_json_error( array( 'message' => $response->get_error_message() ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$items = array();
if ( ! empty( $body['QueryResponse']['Item'] ) && is_array( $body['QueryResponse']['Item'] ) ) {
foreach ( $body['QueryResponse']['Item'] as $item ) {
if ( ! empty( $item['Id'] ) && ! empty( $item['Name'] ) ) {
$items[ (string) $item['Id'] ] = $item['Name'];
}
}
}
wp_send_json_success( $items );
}
protected function get_customer_fields() {
// Field set mirrors QBO's Customer resource. Display fields are
// surfaced via merge tags from the form trigger.
return array(
array( 'key' => 'customer_id', 'value' => __( 'Existing Customer ID (skips create)', 'advanced-form-integration' ) ),
array( 'key' => 'display_name', 'value' => __( 'Display Name', 'advanced-form-integration' ), 'description' => __( 'Required if no Company / First-Last combination is provided. Must be unique in QuickBooks.', 'advanced-form-integration' ) ),
array( 'key' => 'given_name', 'value' => __( 'First Name', 'advanced-form-integration' ) ),
array( 'key' => 'family_name', 'value' => __( 'Last Name', 'advanced-form-integration' ) ),
array( 'key' => 'company_name', 'value' => __( 'Company Name', 'advanced-form-integration' ) ),
array( 'key' => 'email', 'value' => __( 'Email', 'advanced-form-integration' ) ),
array( 'key' => 'phone', 'value' => __( 'Phone', 'advanced-form-integration' ) ),
array( 'key' => 'mobile', 'value' => __( 'Mobile', 'advanced-form-integration' ) ),
array( 'key' => 'website', 'value' => __( 'Website', 'advanced-form-integration' ) ),
array( 'key' => 'address_line1', 'value' => __( 'Billing Address Line 1', 'advanced-form-integration' ) ),
array( 'key' => 'address_line2', 'value' => __( 'Billing Address Line 2', 'advanced-form-integration' ) ),
array( 'key' => 'city', 'value' => __( 'City', 'advanced-form-integration' ) ),
array( 'key' => 'state', 'value' => __( 'State / Region', 'advanced-form-integration' ) ),
array( 'key' => 'postal_code', 'value' => __( 'Postal / ZIP Code', 'advanced-form-integration' ) ),
array( 'key' => 'country', 'value' => __( 'Country', 'advanced-form-integration' ) ),
array( 'key' => 'notes', 'value' => __( 'Notes', 'advanced-form-integration' ), 'type' => 'textarea' ),
);
}
protected function get_invoice_fields() {
$customer_fields = $this->get_customer_fields();
// Pull out the lookup row — invoice context uses customer_id
// separately below for clarity.
$customer_fields = array_values( array_filter( $customer_fields, function ( $f ) {
return 'customer_id' !== $f['key'];
} ) );
$invoice_fields = array(
array( 'key' => 'customer_id', 'value' => __( 'Existing QBO Customer ID', 'advanced-form-integration' ), 'description' => __( 'If empty, AFI will create or upsert the customer from the fields below before creating the invoice.', 'advanced-form-integration' ) ),
array( 'key' => 'doc_number', 'value' => __( 'Document / Invoice Number', 'advanced-form-integration' ) ),
array( 'key' => 'txn_date', 'value' => __( 'Transaction Date (YYYY-MM-DD)', 'advanced-form-integration' ) ),
array( 'key' => 'due_date', 'value' => __( 'Due Date (YYYY-MM-DD)', 'advanced-form-integration' ) ),
array( 'key' => 'currency', 'value' => __( 'Currency Code (e.g. USD)', 'advanced-form-integration' ) ),
array( 'key' => 'bill_email', 'value' => __( 'Billing Email (for send_invoice)', 'advanced-form-integration' ) ),
array( 'key' => 'private_note', 'value' => __( 'Private Note', 'advanced-form-integration' ), 'type' => 'textarea' ),
array( 'key' => 'customer_memo','value' => __( 'Message on Invoice', 'advanced-form-integration' ), 'type' => 'textarea' ),
array( 'key' => 'lines_json', 'value' => __( 'Invoice Lines (JSON Array)', 'advanced-form-integration' ), 'type' => 'textarea', 'description' => __( 'Example: [{"Amount":100,"DetailType":"SalesItemLineDetail","SalesItemLineDetail":{"ItemRef":{"value":"1"},"Qty":1,"UnitPrice":100}}]', 'advanced-form-integration' ) ),
array( 'key' => 'send_email', 'value' => __( 'Send invoice email after create (true/false)', 'advanced-form-integration' ) ),
);
return array_merge( $customer_fields, $invoice_fields );
}
public function handle_job_queue( $data ) {
$record = isset( $data['record'] ) ? $data['record'] : array();
$posted_data = isset( $data['posted_data'] ) ? $data['posted_data'] : array();
if ( empty( $record ) ) {
return;
}
$record_data = json_decode( $record['data'], true );
if ( adfoin_check_conditional_logic( $record_data['action_data']['cl'] ?? array(), $posted_data ) ) {
return;
}
$field_data = isset( $record_data['field_data'] ) ? $record_data['field_data'] : array();
$cred_id = isset( $field_data['credId'] ) ? $field_data['credId'] : '';
$task = isset( $record['task'] ) ? $record['task'] : '';
if ( ! $cred_id ) {
adfoin_add_to_log( new WP_Error( 'quickbooksonline_missing_cred', __( 'No QuickBooks account selected.', 'advanced-form-integration' ) ), '', array(), $record );
return;
}
$this->set_credentials_by_id( $cred_id );
if ( 'create_invoice' === $task ) {
$this->process_invoice( $record, $field_data, $posted_data );
} else {
$this->process_customer( $record, $field_data, $posted_data );
}
}
protected function process_customer( $record, $field_data, $posted_data ) {
$customer_payload = $this->collect_customer_payload( $field_data, $posted_data );
if ( empty( $customer_payload ) ) {
adfoin_add_to_log( new WP_Error( 'quickbooksonline_empty_customer', __( 'No customer fields mapped.', 'advanced-form-integration' ) ), '', array(), $record );
return;
}
$existing_id = $this->parse_field_value( $field_data, 'customer_id', $posted_data );
if ( '' === $existing_id && ! empty( $customer_payload['PrimaryEmailAddr']['Address'] ) ) {
$existing_id = $this->find_customer_id_by_email( $customer_payload['PrimaryEmailAddr']['Address'] );
}
if ( $existing_id ) {
$sync_token = $this->get_customer_sync_token( $existing_id );
if ( '' === $sync_token ) {
return;
}
$customer_payload['Id'] = (string) $existing_id;
$customer_payload['SyncToken'] = (string) $sync_token;
$customer_payload['sparse'] = true; // partial update
$this->qbo_request( 'customer', 'POST', $customer_payload, $record );
return;
}
$this->qbo_request( 'customer', 'POST', $customer_payload, $record );
}
protected function process_invoice( $record, $field_data, $posted_data ) {
$customer_id = $this->parse_field_value( $field_data, 'customer_id', $posted_data );
if ( '' === $customer_id ) {
// Build the customer first, with email-based upsert.
$customer_payload = $this->collect_customer_payload( $field_data, $posted_data );
if ( empty( $customer_payload ) ) {
adfoin_add_to_log( new WP_Error( 'quickbooksonline_missing_customer', __( 'Provide a Customer ID or map customer fields to create one.', 'advanced-form-integration' ) ), '', array(), $record );
return;
}
if ( ! empty( $customer_payload['PrimaryEmailAddr']['Address'] ) ) {
$customer_id = $this->find_customer_id_by_email( $customer_payload['PrimaryEmailAddr']['Address'] );
}
if ( ! $customer_id ) {
$response = $this->qbo_request( 'customer', 'POST', $customer_payload, $record );
if ( is_wp_error( $response ) ) {
return;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$customer_id = isset( $body['Customer']['Id'] ) ? (string) $body['Customer']['Id'] : '';
}
if ( ! $customer_id ) {
return;
}
}
$invoice_payload = $this->collect_invoice_payload( $field_data, $posted_data, $customer_id );
if ( is_wp_error( $invoice_payload ) ) {
adfoin_add_to_log( $invoice_payload, '', array(), $record );
return;
}
$response = $this->qbo_request( 'invoice', 'POST', $invoice_payload, $record );
if ( is_wp_error( $response ) ) {
return;
}
$send_flag = strtolower( (string) $this->parse_field_value( $field_data, 'send_email', $posted_data ) );
if ( in_array( $send_flag, array( 'true', '1', 'yes', 'on' ), true ) ) {
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$invoice_id = isset( $body['Invoice']['Id'] ) ? (string) $body['Invoice']['Id'] : '';
$bill_email = $this->parse_field_value( $field_data, 'bill_email', $posted_data );
if ( $invoice_id ) {
$args = array();
if ( '' !== $bill_email ) {
$args['sendTo'] = $bill_email;
}
$this->qbo_request( 'invoice/' . rawurlencode( $invoice_id ) . '/send', 'POST', array(), $record, $args );
}
}
}
/**
* Public so Pro can reuse the same mapping for WC-autofill paths.
*/
public function collect_customer_payload( $field_data, $posted_data ) {
$payload = array();
// Identity
$given = $this->parse_field_value( $field_data, 'given_name', $posted_data );
$family = $this->parse_field_value( $field_data, 'family_name', $posted_data );
$display = $this->parse_field_value( $field_data, 'display_name', $posted_data );
$company = $this->parse_field_value( $field_data, 'company_name', $posted_data );
if ( $given ) { $payload['GivenName'] = $given; }
if ( $family ) { $payload['FamilyName'] = $family; }
if ( $company ) { $payload['CompanyName'] = $company; }
if ( $display ) { $payload['DisplayName'] = $display; }
// DisplayName is required when neither a Company nor a
// FullyQualifiedName resolves — fall back to the most
// distinctive available value to avoid a 400.
if ( empty( $payload['DisplayName'] ) ) {
if ( $company ) {
$payload['DisplayName'] = $company;
} elseif ( $given || $family ) {
$payload['DisplayName'] = trim( $given . ' ' . $family );
}
}
$email = $this->parse_field_value( $field_data, 'email', $posted_data );
if ( $email ) {
$payload['PrimaryEmailAddr'] = array( 'Address' => $email );
}
$phone = $this->parse_field_value( $field_data, 'phone', $posted_data );
if ( $phone ) { $payload['PrimaryPhone'] = array( 'FreeFormNumber' => $phone ); }
$mobile = $this->parse_field_value( $field_data, 'mobile', $posted_data );
if ( $mobile ) { $payload['Mobile'] = array( 'FreeFormNumber' => $mobile ); }
$website = $this->parse_field_value( $field_data, 'website', $posted_data );
if ( $website ) { $payload['WebAddr'] = array( 'URI' => $website ); }
// Billing address — QBO accepts the address as a nested object.
$address = array();
foreach ( array(
'address_line1' => 'Line1',
'address_line2' => 'Line2',
'city' => 'City',
'state' => 'CountrySubDivisionCode',
'postal_code' => 'PostalCode',
'country' => 'Country',
) as $form_key => $api_key ) {
$value = $this->parse_field_value( $field_data, $form_key, $posted_data );
if ( '' !== $value ) {
$address[ $api_key ] = $value;
}
}
if ( ! empty( $address ) ) {
$payload['BillAddr'] = $address;
}
$notes = $this->parse_field_value( $field_data, 'notes', $posted_data );
if ( $notes ) {
$payload['Notes'] = $notes;
}
return $payload;
}
/**
* Build a QBO Invoice payload. Returns WP_Error when the invoice
* lines JSON is invalid or empty (Pro overrides this with WC items
* before calling).
*/
public function collect_invoice_payload( $field_data, $posted_data, $customer_id, $extra_lines = array() ) {
$lines = array();
if ( ! empty( $extra_lines ) ) {
$lines = array_merge( $lines, $extra_lines );
}
$lines_raw = $this->parse_field_value( $field_data, 'lines_json', $posted_data );
if ( '' !== $lines_raw ) {
$decoded = json_decode( $lines_raw, true );
if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
$lines = array_merge( $lines, $decoded );
}
}
if ( empty( $lines ) ) {
return new WP_Error( 'quickbooksonline_missing_lines', __( 'Cannot create invoice: no invoice lines.', 'advanced-form-integration' ) );
}
// QBO needs DetailType + SalesItemLineDetail per line. If a
// caller supplied bare {Description, Amount, ItemRef.value},
// wrap it into the documented shape so we don't 400.
foreach ( $lines as &$line ) {
if ( ! is_array( $line ) ) {
continue;
}
if ( empty( $line['DetailType'] ) ) {
$line['DetailType'] = 'SalesItemLineDetail';
}
if ( empty( $line['SalesItemLineDetail'] ) && isset( $line['ItemRef'] ) ) {
$line['SalesItemLineDetail'] = array( 'ItemRef' => $line['ItemRef'] );
unset( $line['ItemRef'] );
}
}
unset( $line );
$invoice = array(
'CustomerRef' => array( 'value' => (string) $customer_id ),
'Line' => $lines,
);
$doc_number = $this->parse_field_value( $field_data, 'doc_number', $posted_data );
if ( '' !== $doc_number ) { $invoice['DocNumber'] = $doc_number; }
$txn_date = $this->parse_field_value( $field_data, 'txn_date', $posted_data );
if ( '' !== $txn_date ) { $invoice['TxnDate'] = $txn_date; }
$due_date = $this->parse_field_value( $field_data, 'due_date', $posted_data );
if ( '' !== $due_date ) { $invoice['DueDate'] = $due_date; }
$currency = $this->parse_field_value( $field_data, 'currency', $posted_data );
if ( '' !== $currency ) { $invoice['CurrencyRef'] = array( 'value' => strtoupper( $currency ) ); }
$bill_email = $this->parse_field_value( $field_data, 'bill_email', $posted_data );
if ( '' !== $bill_email ) { $invoice['BillEmail'] = array( 'Address' => $bill_email ); }
$private_note = $this->parse_field_value( $field_data, 'private_note', $posted_data );
if ( '' !== $private_note ) { $invoice['PrivateNote'] = $private_note; }
$customer_memo = $this->parse_field_value( $field_data, 'customer_memo', $posted_data );
if ( '' !== $customer_memo ) { $invoice['CustomerMemo'] = array( 'value' => $customer_memo ); }
return $invoice;
}
/**
* Find a customer by email — used as the upsert key when neither
* a Customer ID nor DisplayName uniqueness is guaranteed. Returns
* the QBO Id or '' on miss.
*/
public function find_customer_id_by_email( $email ) {
if ( '' === (string) $email ) {
return '';
}
// Escape single quotes per QBO query syntax (SQL-ish, ' → '').
$escaped = str_replace( "'", "''", (string) $email );
$response = $this->qbo_request(
'query',
'GET',
array(),
array(),
array( 'query' => "select Id from Customer where PrimaryEmailAddr = '{$escaped}'" )
);
if ( is_wp_error( $response ) ) {
return '';
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! empty( $body['QueryResponse']['Customer'][0]['Id'] ) ) {
return (string) $body['QueryResponse']['Customer'][0]['Id'];
}
return '';
}
/**
* QBO's optimistic-locking update flow requires the current
* SyncToken on the resource. Fetch it before the sparse update.
*/
public function get_customer_sync_token( $customer_id ) {
$response = $this->qbo_request( 'customer/' . rawurlencode( $customer_id ), 'GET' );
if ( is_wp_error( $response ) ) {
return '';
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return isset( $body['Customer']['SyncToken'] ) ? (string) $body['Customer']['SyncToken'] : '';
}
/**
* Resolve a template tag against the posted form data and trim.
* Public so Pro can call it from its own send-data path.
*/
public function parse_field_value( $field_data, $key, $posted_data ) {
if ( ! isset( $field_data[ $key ] ) ) {
return '';
}
$value = adfoin_get_parsed_values( $field_data[ $key ], $posted_data );
if ( is_array( $value ) ) {
return '';
}
return is_string( $value ) ? trim( $value ) : (string) $value;
}
}
ADFOIN_QuickBooksOnline::get_instance();
function adfoin_quickbooksonline_send_data( $record, $posted_data ) {
ADFOIN_QuickBooksOnline::get_instance()->handle_job_queue( array(
'record' => $record,
'posted_data' => $posted_data,
) );
}