| @@ -334,9 +334,10 @@ | ||
| 334 | 334 | * Record the SaaS connection-health state (#208). |
| 335 | 335 | * |
| 336 | 336 | * Stores array{status, since} in the mlsimport_connection_health option: |
| 337 | 337 | * 'healthy', 'credentials_invalid' (server rejected the stored account), |
| 338 | - * or 'credentials_missing' (nothing configured). Transient failures such | |
| 338 | + * 'no_subscription' (password accepted, account not active — #322) or | |
| 339 | + * 'credentials_missing' (nothing configured). Transient failures such | |
| 339 | 340 | * as network timeouts never call this, so a working state is not lost to |
| 340 | 341 | * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps |
| 341 | 342 | * pointing at when the state actually began. |
| 342 | 343 | * |
| @@ -372,9 +373,12 @@ | ||
| 372 | 373 | * Request a fresh JWT from the SaaS 'token' endpoint and cache it. |
| 373 | 374 | * |
| 374 | 375 | * Reads the stored username/password, POSTs them, and on success stores the |
| 375 | 376 | * token in a transient plus the expiry timestamp in an option. Bumps the |
| 376 | - * 'token_failures' telemetry counter on every failure path. | |
| 377 | + * 'token_failures' telemetry counter on every failure path — WITHOUT a | |
| 378 | + * connection id (#283): the SaaS JWT is account-level, shared by every | |
| 379 | + * connection, so its failures belong to no single MLS and count only in | |
| 380 | + * the global bucket. | |
| 377 | 381 | * |
| 378 | 382 | * @return bool True on successful refresh, false otherwise. |
| 379 | 383 | */ |
| 380 | 384 | private static function refreshToken() { |
| @@ -434,21 +438,29 @@ | ||
| 434 | 438 | |
| 435 | 439 | // Decode the JSON token response. |
| 436 | 440 | $body = wp_remote_retrieve_body($response); |
| 437 | 441 | $data = json_decode($body, true); |
| 442 | + $code = intval( $response['response']['code'] ?? 0 ); | |
| 438 | 443 | |
| 439 | 444 | // Reject any response missing success/token/expires. |
| 440 | 445 | if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) { |
| 441 | 446 | mlsimport_telemetry_bump( 'token_failures' ); |
| 442 | 447 | delete_option( 'mlsimport_token_refresh_lock' ); |
| 443 | - // The server answered and said no → the credentials themselves are | |
| 444 | - // bad (terminal until the user fixes them). A malformed/partial | |
| 445 | - // body is a server hiccup instead and leaves health untouched. | |
| 448 | + // The server answered and said no → terminal until the user acts. | |
| 449 | + // HTTP 403 means the password was right but the account has no | |
| 450 | + // active subscription (#322); anything else is bad credentials. | |
| 451 | + // A malformed/partial body is a server hiccup instead and leaves | |
| 452 | + // health untouched. | |
| 446 | 453 | if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) { |
| 447 | - self::setConnectionHealth( 'credentials_invalid' ); | |
| 454 | + self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' ); | |
| 455 | + // Same verdict, remembered for the "not connected" screens. | |
| 456 | + mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) ); | |
| 448 | 457 | } |
| 449 | 458 | return false; |
| 450 | 459 | } |
| 460 | + | |
| 461 | + // A working login wipes any remembered failure reason (#322). | |
| 462 | + mlsimport_account_status_record( $data ); | |
| 451 | 463 | |
| 452 | 464 | // Store new token and expiry |
| 453 | 465 | //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']); |
| 454 | 466 | |
| @@ -668,8 +680,18 @@ | ||
| 668 | 680 | // Capture the owning task before its meta row is deleted below; the |
| 669 | 681 | // success activity entry still needs it afterward. |
| 670 | 682 | $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true)); |
| 671 | 683 | |
| 684 | + // Dedupe (issue #282): this raw-SQL path bypasses the WP delete | |
| 685 | + // hooks, so capture the address group now (meta is gone after the | |
| 686 | + // raw delete) and re-evaluate it after success — deleting a flagged | |
| 687 | + // winner must promote its hidden loser. | |
| 688 | + $dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true); | |
| 689 | + // Telemetry (#283): the deletion counts against the | |
| 690 | + // listing's OWN connection — read the provenance stamp | |
| 691 | + // (#278) before the raw delete wipes its meta. | |
| 692 | + $provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true); | |
| 693 | + | |
| 672 | 694 | global $wpdb; |
| 673 | 695 | // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the |
| 674 | 696 | // property's term relationships, term counts or listings row. Do that |
| 675 | 697 | // cleanup explicitly (SQL-first) before removing the post itself. |
| @@ -696,11 +718,17 @@ | ||
| 696 | 718 | foreach ($attachments as $attachmentId) { |
| 697 | 719 | wp_delete_attachment($attachmentId, true); |
| 698 | 720 | } |
| 699 | 721 | |
| 722 | + // Dedupe (issue #282): the post row is durably gone — settle the | |
| 723 | + // surviving copies of its address group (promote a hidden loser). | |
| 724 | + if ('' !== $dedupeAddressKey && function_exists('mlsimport_dedupe_evaluate')) { | |
| 725 | + mlsimport_dedupe_evaluate($dedupeAddressKey, (string) $postType); | |
| 726 | + } | |
| 727 | + | |
| 700 | 728 | // Record the deletion in the activity feed only after it happened. |
| 701 | 729 | mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' ); |
| 702 | - mlsimport_telemetry_bump( 'deleted' ); | |
| 730 | + mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId ); | |
| 703 | 731 | |
| 704 | 732 | $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL; |
| 705 | 733 | $this->writeImportLogs($logEntry, 'delete'); |
| 706 | 734 | } |
| @@ -741,10 +769,15 @@ | ||
| 741 | 769 | return false; |
| 742 | 770 | } |
| 743 | 771 | |
| 744 | 772 | // Translate the shallow legacy option array into the stable module settings. |
| 773 | + // The listing's provenance (issue #278) is the task's OWN connection binding | |
| 774 | + // (#277) read straight from post meta — deliberately NO current-connection | |
| 775 | + // fallback on the write path (decision #266): an unbound task stamps 0 | |
| 776 | + // rather than silently adopting whichever connection is globally selected. | |
| 745 | 777 | $settings = array( |
| 746 | 778 | 'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ), |
| 779 | + 'mls_id' => (int) get_post_meta( (int) ( $itemIdArray['item_id'] ?? 0 ), 'mlsimport_item_mls_id', true ), | |
| 747 | 780 | 'source' => (string) $tipImport, |
| 748 | 781 | 'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null ) |
| 749 | 782 | ? $mlsimportItemOptionData['mlsimport_item_standardstatus'] |
| 750 | 783 | : array(), |