diff --git a/README.md b/README.md index 4fca8b1..63eabbc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,90 @@ This add-on to the main [CK Join Flow plugin](https://github.com/commonknowledge - **Branch tagging** — Adds the assigned branch name as a tag when members are synced to external services (Mailchimp, Zetkin, etc.). - **Email notifications** — Sends admin and branch-specific notification emails when a new member registers. - **Postcode lookup caching** — Caches postcodes.io API responses as WordPress transients (7-day TTL) to reduce external API calls. +- **Membership lapsing override** — Applies GMTU's own standing rules instead of lapsing members immediately on Stripe payment failure. + +## Hook lifecycle + +The parent plugin fires hooks at each stage of member registration and membership management. This plugin hooks into them in the following order: + +| # | Hook | File | What we do | +|---|------|------|------------| +| 1 | `ck_join_flow_postcode_validation` (filter) | `PostcodeValidation.php` | Check outcode against branch map; return error if out of area | +| 2 | `ck_join_flow_step_response` (filter) | `PostcodeValidation.php` | Second-line validation on form step submission | +| 3 | `ck_join_flow_pre_handle_join` (filter) | `BranchAssignment.php` | Look up postcode outcode, find branch, inject into `$data["branch"]` | +| 4 | `ck_join_flow_add_tags` (filter) | `Tagging.php` | Append branch name to tags sent to external services | +| 5 | `ck_join_flow_success` (action, priority 5) | `LapsingOverride.php` | Clear sticky-lapsed flag when a member explicitly rejoins | +| 6 | `ck_join_flow_success` (action, priority 10) | `Notifications.php` | Send admin notification email | +| 7 | `ck_join_flow_success` (action, priority 20) | `Notifications.php` | Send branch-specific notification email | +| 8 | `ck_join_flow_should_lapse_member` (filter) | `LapsingOverride.php` | Override lapse decision using GMTU standing rules (see below) | +| 9 | `ck_join_flow_should_unlapse_member` (filter) | `LapsingOverride.php` | Override unlapse decision using GMTU standing rules (see below) | + +## Membership lapsing override + +### Why this exists + +Stripe fires webhook events whenever a payment fails or a subscription is cancelled. The parent plugin responds to these by marking the member as lapsed in all configured integrations. For GMTU, this is too aggressive — a single missed payment does not mean a member has lapsed under GMTU's rules. + +This plugin intercepts the parent plugin's lapsing decisions and applies GMTU's own standing classification instead. + +### Standing classification rules + +Membership standing is classified by counting **completed calendar months** since the member's last successful GMTU payment. The current in-progress month is always excluded from this count. + +| Missed completed months | Status | +|------------------------|--------| +| 0–2 | Good standing | +| 3 | Early arrears | +| 4–6 | Lapsing | +| 7 or more | **Lapsed** | + +Additional rules: + +- **Only GMTU payments count.** Payments are identified by Stripe charge metadata (`id = "join-gmtu"`). Other charges on the same Stripe customer are ignored. +- **Failed and refunded payments do not count** as paid months. +- **Lapsed is permanent.** Once a member reaches Lapsed status, a later payment does not automatically reinstate them. They must rejoin via the join form. This state is stored persistently in the WordPress database (see below). +- **New member exception.** If someone makes their very first successful GMTU payment in the current month, they are treated as Good standing immediately. + +### How the override hooks work + +**`ck_join_flow_should_lapse_member`** + +Called by the parent plugin when a Stripe payment event signals that a member should be lapsed. This plugin: + +1. Fetches the member's GMTU payment history from the Stripe Charges API. +2. Classifies their standing using the rules above. +3. Returns `true` (allow lapse) only if the member is classified as **Lapsed** (7+ missed months). Records the lapsed flag. +4. Returns `false` (suppress lapse) for Good standing, Early arrears, or Lapsing -- the parent plugin is acting more aggressively than GMTU rules require. +5. If the member has no GMTU payment history at all, logs a warning and passes through to the parent plugin default. +6. Falls through to the parent plugin default on Stripe API errors, to avoid accidental lapsing due to a transient network failure. + +**`ck_join_flow_should_unlapse_member`** + +Called by the parent plugin when a Stripe payment event signals that a member should be unlapsed (e.g. after a successful payment). This plugin: + +1. Fetches the member's GMTU payment history and classifies their standing. +2. Returns `true` (allow unlapse) only if the member is **Good standing** and is not flagged as lapsed. +3. Returns `false` (suppress unlapse) if the member is lapsed -- they must rejoin explicitly via the join form. +4. Returns `false` if the member is in Early arrears or Lapsing -- one payment is not enough to restore Good standing. +5. Falls through to the parent plugin default on Stripe API errors. + +**`ck_join_flow_success` (priority 5)** + +When a member completes the join form successfully, the lapsed flag is cleared. This is what allows a previously-lapsed member to regain Good standing, but only after going through the full join flow again. + +### Example + +Suppose today is 15 August. The last completed month is July. + +| Last payment | Missed months | Status | Lapse webhook outcome | +|---|---|---|---| +| April | May, Jun, Jul (3) | Early arrears | Suppressed | +| January | Feb, Mar, Apr, May, Jun, Jul (6) | Lapsing | Suppressed | +| December (prior year) | Jan through Jul (7) | Lapsed | Allowed; lapsed flag recorded | + +### Lapsed flag storage + +The lapsed flag is stored in WordPress `wp_options`, keyed by `gmtu_lapsed_` followed by the SHA-256 hash of the member's lowercased email address. The stored value is a JSON object recording the email, timestamp, and webhook trigger, for audit purposes. The flag is cleared automatically when the member completes a new join form submission. ## Structure @@ -24,6 +108,9 @@ src/ BranchAssignment.php # Assigns branch to member data based on postcode Tagging.php # Adds branch as tag in external services Notifications.php # Registers success notification hooks + MembershipStanding.php # Pure GMTU standing classifier (no I/O, fully unit-tested) + LapsedStore.php # Persists lapsed flag in wp_options + LapsingOverride.php # Hooks into parent lapsing filters using the above two ``` ## Configuration diff --git a/composer.json b/composer.json index 57eaba6..ea9e2ce 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "phpunit/phpunit": "^9.6", "brain/monkey": "^2.6", "mockery/mockery": "^1.6", - "yoast/phpunit-polyfills": "^2.0" + "yoast/phpunit-polyfills": "^2.0", + "stripe/stripe-php": "^16.1" }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index ed835ab..136fcaf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d134a8a80e32d93d1564cca479d5f55d", + "content-hash": "5deee947e5548c55bfc8cd1791b9ff27", "packages": [], "packages-dev": [ { @@ -2005,6 +2005,65 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v16.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "reference": "d6de0a536f00b5c5c74f36b8f4d0d93b035499ff", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v16.6.0" + }, + "time": "2025-02-24T22:35:29+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/join-gmtu.php b/join-gmtu.php index a07181f..f76f91f 100644 --- a/join-gmtu.php +++ b/join-gmtu.php @@ -40,11 +40,28 @@ * - Receives: $addTags, $data, $service * - We append the branch name to the tags array. * - * 5. ck_join_flow_success (action, Notifications.php) + * 5. ck_join_flow_success (action, LapsingOverride.php, priority 5) * - Fired after successful registration. * - Receives: $data - * - Priority 10: sends admin notification email. - * - Priority 20: sends branch-specific notification email. + * - Clears the sticky-lapsed flag so a rejoining member regains Good standing. + * + * 6. ck_join_flow_success (action, Notifications.php, priority 10) + * - Sends admin notification email. + * + * 7. ck_join_flow_success (action, Notifications.php, priority 20) + * - Sends branch-specific notification email. + * + * 8. ck_join_flow_should_lapse_member (filter, LapsingOverride.php) + * - Fired when Stripe signals a member should be lapsed. + * - Receives: $should_lapse (bool), $email, $context + * - Returns true only when GMTU standing is Lapsed (7+ missed months). + * - Suppresses lapse for Good / Early Arrears / Lapsing standing. + * + * 9. ck_join_flow_should_unlapse_member (filter, LapsingOverride.php) + * - Fired when Stripe signals a member should be unlapsed. + * - Receives: $should_unlapse (bool), $email, $context + * - Returns true only when standing is Good and sticky-lapsed flag is not set. + * - Suppresses unlapse for sticky-lapsed members (must rejoin explicitly). */ // Load required files @@ -57,6 +74,10 @@ require_once __DIR__ . '/src/BranchAssignment.php'; require_once __DIR__ . '/src/Tagging.php'; require_once __DIR__ . '/src/Notifications.php'; +require_once __DIR__ . '/src/MembershipStanding.php'; +require_once __DIR__ . '/src/LapsedStore.php'; +require_once __DIR__ . '/src/StripePaymentHistory.php'; +require_once __DIR__ . '/src/LapsingOverride.php'; // Configuration $config = [ @@ -75,3 +96,4 @@ register_branch_assignment(); register_tagging(); register_notifications($config); +register_lapsing_override(); diff --git a/src/LapsedStore.php b/src/LapsedStore.php new file mode 100644 index 0000000..3b63476 --- /dev/null +++ b/src/LapsedStore.php @@ -0,0 +1,82 @@ + $email, + 'lapsed_at' => $lapsed_at, + 'trigger' => $trigger, + ]); + + // Autoload false -- only looked up on-demand during webhook processing. + update_option(lapsed_option_key($email), $value, false); +} + +/** + * Clear the lapsed flag for a member. + * + * Called when a member explicitly rejoins via the join form, allowing them + * to return to Good standing. + * + * @param string $email Member email address. + * @return void + */ +function clear_lapsed(string $email): void +{ + delete_option(lapsed_option_key($email)); +} diff --git a/src/LapsingOverride.php b/src/LapsingOverride.php new file mode 100644 index 0000000..69df3f6 --- /dev/null +++ b/src/LapsingOverride.php @@ -0,0 +1,118 @@ + $k < $as_of_month_key); + if (empty($prior)) { + return null; + } + return max($prior); +} + +/** + * Classify a member's GMTU membership standing. + * + * Standing is based on the number of completed calendar months since the + * member's last successful payment. The current in-progress month is excluded. + * + * If the member is flagged as lapsed (7+ missed months in a prior assessment), + * that state takes precedence -- a later payment does not reinstate them + * automatically. They must rejoin via the join form. + * + * @param string[] $paid_month_keys 'YYYY-MM' strings for months with a successful payment. + * @param string $as_of_month_key Current month 'YYYY-MM' (the in-progress month, excluded). + * @param bool $is_lapsed Whether the member has previously been marked as lapsed. + * @return string One of the STANDING_* constants. + */ +function classify_membership_standing( + array $paid_month_keys, + string $as_of_month_key, + bool $is_lapsed = false +): string { + // Lapsed always wins -- a later payment does not reinstate automatically. + if ($is_lapsed) { + return STANDING_LAPSED; + } + + $last_paid = last_paid_month_before($paid_month_keys, $as_of_month_key); + + // New member exception: first-ever payment is in the current month and there + // are no prior payments -- treat as Good standing immediately. + if ($last_paid === null && in_array($as_of_month_key, $paid_month_keys, true)) { + return STANDING_GOOD; + } + + // No payment history at all (before or during current month). + if ($last_paid === null) { + return STANDING_LAPSED; + } + + $missed = count_missed_completed_months($last_paid, $as_of_month_key); + + if ($missed <= 2) { + return STANDING_GOOD; + } + if ($missed === 3) { + return STANDING_EARLY_ARREARS; + } + if ($missed <= 6) { + return STANDING_LAPSING; + } + return STANDING_LAPSED; +} diff --git a/src/StripePaymentHistory.php b/src/StripePaymentHistory.php new file mode 100644 index 0000000..6c67472 --- /dev/null +++ b/src/StripePaymentHistory.php @@ -0,0 +1,216 @@ + \Stripe\Customer::all($p); + $list_subscriptions = $subscription_lister ?? fn($p) => \Stripe\Subscription::all($p); + $list_invoices = $invoice_lister ?? fn($p) => \Stripe\Invoice::all($p); + + try { + $stripe_key = \CommonKnowledge\JoinBlock\Settings::get('STRIPE_SECRET_KEY'); + if (empty($stripe_key)) { + return ['month_keys' => [], 'error' => 'Stripe secret key not configured']; + } + + \Stripe\Stripe::setApiKey($stripe_key); + + $product_ids = $get_product_ids(); + if (empty($product_ids)) { + return ['month_keys' => [], 'error' => 'No membership plans configured']; + } + + // A single email address may have multiple Stripe customer records. + $customers = $list_customers(['email' => $email, 'limit' => 10]); + + $timestamps = []; + + foreach ($customers->data as $customer) { + $has_more = true; + $params = ['customer' => $customer->id, 'status' => 'all', 'limit' => 100]; + + while ($has_more) { + $subscriptions = $list_subscriptions($params); + $has_more = $subscriptions->has_more; + + foreach ($subscriptions->data as $subscription) { + if ($has_more) { + $params['starting_after'] = $subscription->id; + } + + if (!is_gmtu_subscription($subscription, $product_ids)) { + continue; + } + + $timestamps = array_merge( + $timestamps, + fetch_paid_invoice_timestamps($customer->id, $subscription->id, $list_invoices) + ); + } + } + } + + if (empty($timestamps)) { + return ['month_keys' => [], 'error' => null]; + } + + sort($timestamps); + $month_keys = array_values(array_unique( + array_map(fn(int $ts) => gmdate('Y-m', $ts), $timestamps) + )); + + return ['month_keys' => $month_keys, 'error' => null]; + + } catch (\Stripe\Exception\ApiErrorException $e) { + return ['month_keys' => [], 'error' => $e->getMessage()]; + } catch (\Throwable $e) { + return ['month_keys' => [], 'error' => $e->getMessage()]; + } +} + +/** + * Retrieve all configured membership product IDs from WordPress options. + * + * Reads every option with the 'ck_join_flow_membership_plan_' prefix (stored + * by the parent plugin when plans are saved) and collects their + * 'stripe_product_id' values. + * + * @return string[] Stripe product IDs for all configured membership plans. + */ +function get_membership_product_ids(): array +{ + global $wpdb; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like('ck_join_flow_membership_plan_') . '%' + ), + ARRAY_A + ); + + $product_ids = []; + foreach ($rows as $row) { + $plan = maybe_unserialize($row['option_value']); + if (is_array($plan) && !empty($plan['stripe_product_id'])) { + $product_ids[] = $plan['stripe_product_id']; + } + } + + return array_values(array_unique($product_ids)); +} + +/** + * Determine whether a Stripe subscription is a GMTU membership subscription. + * + * A subscription qualifies if at least one of its line items belongs to a + * configured membership product. + * + * @param \Stripe\Subscription $subscription + * @param string[] $product_ids Configured membership product IDs. + * @return bool + */ +function is_gmtu_subscription(\Stripe\Subscription $subscription, array $product_ids): bool +{ + foreach ($subscription->items->data as $item) { + $product = $item->price->product ?? null; + if (is_string($product) && in_array($product, $product_ids, true)) { + return true; + } + // Price may have an expanded Product object rather than a bare ID. + if ($product instanceof \Stripe\Product && in_array($product->id, $product_ids, true)) { + return true; + } + } + return false; +} + +/** + * Fetch timestamps of paid invoices for a given subscription. + * + * Uses the Stripe Invoices API, paginating through all paid invoices for the + * specified customer and subscription. Returns the unix timestamp at which + * each invoice was paid (status_transitions->paid_at). + * + * @param string $customer_id Stripe customer ID. + * @param string $subscription_id Stripe subscription ID. + * @param callable $invoice_lister fn(array $params): object — wraps Invoice::all. + * @return int[] + */ +function fetch_paid_invoice_timestamps( + string $customer_id, + string $subscription_id, + callable $invoice_lister +): array { + $timestamps = []; + $has_more = true; + $params = [ + 'customer' => $customer_id, + 'subscription' => $subscription_id, + 'status' => 'paid', + 'limit' => 100, + ]; + + while ($has_more) { + $invoices = $invoice_lister($params); + $has_more = $invoices->has_more; + + foreach ($invoices->data as $invoice) { + if ($has_more) { + $params['starting_after'] = $invoice->id; + } + + $paid_at = $invoice->status_transitions->paid_at ?? null; + if (is_int($paid_at) && $paid_at > 0) { + $timestamps[] = $paid_at; + } + } + } + + return $timestamps; +} diff --git a/tests/LapsedStoreTest.php b/tests/LapsedStoreTest.php new file mode 100644 index 0000000..6544774 --- /dev/null +++ b/tests/LapsedStoreTest.php @@ -0,0 +1,105 @@ +assertSame($expected, lapsed_option_key($email)); + } + + public function test_option_key_is_case_insensitive() + { + $this->assertSame( + lapsed_option_key('Test@Example.COM'), + lapsed_option_key('test@example.com') + ); + } + + // --- is_lapsed --- + + public function test_is_lapsed_returns_false_when_option_missing() + { + Functions\expect('get_option') + ->once() + ->with(lapsed_option_key('member@example.com'), false) + ->andReturn(false); + + $this->assertFalse(is_lapsed('member@example.com')); + } + + public function test_is_lapsed_returns_true_when_option_exists() + { + $value = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-04-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option') + ->once() + ->andReturn($value); + + $this->assertTrue(is_lapsed('member@example.com')); + } + + // --- mark_lapsed --- + + public function test_mark_lapsed_stores_json_with_email_trigger_and_timestamp() + { + $email = 'member@example.com'; + $stored = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$stored) { + $stored = json_decode($value, true); + return true; + }); + + mark_lapsed($email, 'invoice_payment_failed', '2026-04-01T00:00:00Z'); + + $this->assertSame($email, $stored['email']); + $this->assertArrayHasKey('lapsed_at', $stored); + $this->assertSame('invoice_payment_failed', $stored['trigger']); + } + + public function test_mark_lapsed_sets_autoload_false() + { + $autoloadValue = null; + + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$autoloadValue) { + $autoloadValue = $autoload; + return true; + }); + + mark_lapsed('member@example.com', 'test', '2026-04-01T00:00:00Z'); + + $this->assertFalse($autoloadValue); + } + + // --- clear_lapsed --- + + public function test_clear_lapsed_calls_delete_option_with_correct_key() + { + $email = 'member@example.com'; + $key = lapsed_option_key($email); + + Functions\expect('delete_option') + ->once() + ->with($key) + ->andReturn(true); + + clear_lapsed($email); + $this->addToAssertionCount(1); + } +} diff --git a/tests/LapsingOverrideTest.php b/tests/LapsingOverrideTest.php new file mode 100644 index 0000000..113f150 --- /dev/null +++ b/tests/LapsingOverrideTest.php @@ -0,0 +1,439 @@ +mockLogger(); + } + + // ------------------------------------------------------------------------- + // Helper: capture all three callbacks registered by register_lapsing_override + // ------------------------------------------------------------------------- + + /** + * Register hooks with an injectable fetcher and capture all three callbacks. + * + * @param callable|null $fetcher Fake fetcher to inject; null = default real fetcher. + * @return array{0: callable, 1: callable, 2: callable} + */ + private function registerAndCaptureCallbacks(?callable $fetcher = null): array + { + $lapseCallback = null; + $unlapseCallback = null; + $successCallback = null; + + Functions\expect('add_filter') + ->twice() + ->andReturnUsing(function ($hook, $cb) use (&$lapseCallback, &$unlapseCallback) { + if ($hook === 'ck_join_flow_should_lapse_member') { + $lapseCallback = $cb; + } elseif ($hook === 'ck_join_flow_should_unlapse_member') { + $unlapseCallback = $cb; + } + return true; + }); + + Functions\expect('add_action') + ->once() + ->andReturnUsing(function ($hook, $cb) use (&$successCallback) { + if ($hook === 'ck_join_flow_success') { + $successCallback = $cb; + } + return true; + }); + + register_lapsing_override($fetcher); + + return [$lapseCallback, $unlapseCallback, $successCallback]; + } + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + + public function test_registers_all_three_hooks() + { + $registered_filters = []; + $registered_actions = []; + + Functions\expect('add_filter') + ->twice() + ->andReturnUsing(function ($hook, $cb, $priority, $args) use (&$registered_filters) { + $registered_filters[] = $hook; + return true; + }); + Functions\expect('add_action') + ->once() + ->andReturnUsing(function ($hook, $cb, $priority) use (&$registered_actions) { + $registered_actions[] = $hook; + return true; + }); + + register_lapsing_override(); + + $this->assertContains('ck_join_flow_should_lapse_member', $registered_filters); + $this->assertContains('ck_join_flow_should_unlapse_member', $registered_filters); + $this->assertContains('ck_join_flow_success', $registered_actions); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_should_lapse_member + // ------------------------------------------------------------------------- + + public function test_lapse_passes_through_for_non_stripe_provider() + { + [$lapse] = $this->registerAndCaptureCallbacks(); + + $result = $lapse(true, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertTrue($result); + + $result = $lapse(false, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_good_standing() + { + // Last payment was last month — 0 missed months → Good + $lastMonth = $this->monthOffset(-1); + Functions\expect('get_option')->andReturn(false); // not lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_early_arrears() + { + // 3 missed months → Early arrears + $threeMonthsAgo = $this->monthOffset(-4); + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$threeMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_suppressed_for_lapsing() + { + // 5 missed months → Lapsing + $fiveMonthsAgo = $this->monthOffset(-6); + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$fiveMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + /** + * The critical threshold: exactly 6 missed months is still Lapsing — suppressed. + * One fewer missed month than the boundary at which lapsing becomes Lapsed. + */ + public function test_lapse_suppressed_at_exactly_six_missed_months() + { + $sixMissed = $this->monthOffset(-7); // paid 7 months ago = 6 completed missed months + Functions\expect('get_option')->andReturn(false); + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$sixMissed])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + } + + public function test_lapse_allowed_and_lapsed_flag_set_for_lapsed() + { + // 8 missed months → Lapsed + $eightMonthsAgo = $this->monthOffset(-9); + Functions\expect('get_option')->andReturn(false); + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$eightMonthsAgo])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + + /** + * The critical threshold: exactly 7 missed months is Lapsed — lapse allowed. + * One more missed month than the upper bound of the Lapsing band. + */ + public function test_lapse_allowed_at_exactly_seven_missed_months() + { + $sevenMissed = $this->monthOffset(-8); // paid 8 months ago = 7 completed missed months + Functions\expect('get_option')->andReturn(false); + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$sevenMissed])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + + /** + * When the lapsed flag is already set in wp_options, a new payment does NOT + * reinstate the member. The flag takes precedence over payment history — + * the member must rejoin explicitly via the join form. + * + * This verifies classify_membership_standing's $is_lapsed=true path is + * correctly threaded through the lapse callback. + */ + public function test_lapse_allowed_when_flag_already_set_despite_recent_payment() + { + $lastMonth = $this->monthOffset(-1); // would be Good standing without the flag + $lapsedJson = json_encode([ + 'email' => 'member@example.com', + 'lapsed_at' => '2026-01-01T00:00:00Z', + 'trigger' => 'invoice_payment_failed', + ]); + Functions\expect('get_option')->andReturn($lapsedJson); // flag is already set + Functions\expect('update_option')->once()->andReturn(true); // mark_lapsed writes again + + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); + } + + public function test_lapse_falls_through_on_stripe_api_error() + { + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcherWithError('Connection timeout')); + + $result = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result); // original default returned + + $result2 = $lapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result2); + } + + public function test_lapse_falls_through_when_no_gmtu_history() + { + // Empty history, no error → not a GMTU member → pass through + [$lapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + $result = $lapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertFalse($result); + + $result2 = $lapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($result2); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_should_unlapse_member + // ------------------------------------------------------------------------- + + public function test_unlapse_passes_through_for_non_stripe_provider() + { + [, $unlapse] = $this->registerAndCaptureCallbacks(); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'gocardless', 'trigger' => 'x']); + $this->assertTrue($result); + } + + public function test_unlapse_allowed_for_good_standing_non_lapsed() + { + $lastMonth = $this->monthOffset(-1); + Functions\expect('get_option')->andReturn(false); // not lapsed + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $unlapse(false, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($result); + } + + public function test_unlapse_suppressed_for_lapsed() + { + $lastMonth = $this->monthOffset(-1); + $lapsedJson = json_encode(['email' => 'member@example.com', 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($lapsedJson); // lapsed + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$lastMonth])); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($result); + } + + public function test_unlapse_suppressed_for_early_arrears() + { + $threeMonthsAgo = $this->monthOffset(-4); + Functions\expect('get_option')->andReturn(false); + + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcher([$threeMonthsAgo])); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($result); + } + + public function test_unlapse_falls_through_on_stripe_api_error() + { + [, $unlapse] = $this->registerAndCaptureCallbacks($this->fakeFetcherWithError('Timeout')); + + $result = $unlapse(true, 'member@example.com', ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // ck_join_flow_success + // ------------------------------------------------------------------------- + + public function test_success_hook_clears_lapsed_flag() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + $email = 'member@example.com'; + $lapsed = json_encode(['email' => $email, 'lapsed_at' => '2026-01-01T00:00:00Z', 'trigger' => 'x']); + Functions\expect('get_option')->andReturn($lapsed); + Functions\expect('delete_option')->once()->andReturn(true); + + $success(['email' => $email]); + $this->addToAssertionCount(1); // delete_option ->once() is the assertion + } + + public function test_success_hook_does_nothing_when_not_lapsed() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + Functions\expect('get_option')->andReturn(false); + Functions\expect('delete_option')->never(); + + $success(['email' => 'member@example.com']); + $this->addToAssertionCount(1); + } + + /** + * When $data contains no 'email' key, the success hook must be a complete + * no-op: it must not call get_option or delete_option, and must not throw. + */ + public function test_success_hook_does_nothing_when_email_missing_from_data() + { + [,, $success] = $this->registerAndCaptureCallbacks($this->fakeFetcher([])); + + Functions\expect('get_option')->never(); + Functions\expect('delete_option')->never(); + + $success([]); // no 'email' key + $this->addToAssertionCount(1); + } + + // ------------------------------------------------------------------------- + // Full rejoin cycle + // ------------------------------------------------------------------------- + + /** + * End-to-end scenario: member lapses → rejoins via join form → next payment + * triggers an allowed unlapse. + * + * This is the most important user journey in the lapsing system. It verifies + * that the three hooks (lapse, success, unlapse) interact correctly through + * shared persistent state, not just in isolation. + * + * Sequence: + * 1. Lapse hook fires for a member with 8+ missed months → lapse allowed, flag set. + * 2. Unlapse hook fires before rejoin → suppressed (flag still set). + * 3. Member completes join form → success hook clears the flag. + * 4. Unlapse hook fires after rejoin with a recent payment → allowed. + */ + public function test_full_rejoin_cycle_clears_lapsed_flag_and_allows_unlapse() + { + $email = 'member@example.com'; + $eightMonthsAgo = $this->monthOffset(-9); // 8 missed months → Lapsed + $lastMonth = $this->monthOffset(-1); // 0 missed months → Good + + // Simulate stateful WordPress option storage in memory. + $store = []; + + Functions\expect('get_option') + ->times(4) // lapse, unlapse-before, success, unlapse-after + ->andReturnUsing(function ($key, $default = false) use (&$store) { + return $store[$key] ?? $default; + }); + Functions\expect('update_option') + ->once() + ->andReturnUsing(function ($key, $value, $autoload) use (&$store) { + $store[$key] = $value; + return true; + }); + Functions\expect('delete_option') + ->once() + ->andReturnUsing(function ($key) use (&$store) { + unset($store[$key]); + return true; + }); + + // The fetcher returns lapsed-era history during lapse/unlapse-before, + // then recent history after the member rejoins. + $phase = 'lapsed'; + [$lapse, $unlapse, $success] = $this->registerAndCaptureCallbacks( + function (string $e) use (&$phase, $eightMonthsAgo, $lastMonth) { + return [ + 'month_keys' => $phase === 'lapsed' ? [$eightMonthsAgo] : [$lastMonth], + 'error' => null, + ]; + } + ); + + // Step 1: Lapse fires → member is Lapsed, flag written to store. + $lapseResult = $lapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_payment_failed']); + $this->assertTrue($lapseResult, 'Step 1: lapse must be allowed for Lapsed member'); + + // Step 2: Unlapse fires before rejoin → must be suppressed (flag is set). + $unlapseBeforeRejoin = $unlapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertFalse($unlapseBeforeRejoin, 'Step 2: unlapse must be suppressed while member is still lapsed'); + + // Step 3: Member completes join form → success hook clears the flag. + $phase = 'rejoined'; + $success(['email' => $email]); + + // Step 4: Unlapse fires after rejoin with a recent payment → must be allowed. + $unlapseAfterRejoin = $unlapse(true, $email, ['provider' => 'stripe', 'trigger' => 'invoice_paid']); + $this->assertTrue($unlapseAfterRejoin, 'Step 4: unlapse must be allowed after explicit rejoin'); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Return a 'YYYY-MM' string offset from the current month. + * e.g. monthOffset(-1) = last month, monthOffset(-7) = 7 months ago. + */ + private function monthOffset(int $offset): string + { + $ts = mktime(0, 0, 0, (int)gmdate('n') + $offset, 1, (int)gmdate('Y')); + return gmdate('Y-m', $ts); + } + + /** + * Return a fake fetcher callable that always returns the given month keys. + */ + private function fakeFetcher(array $monthKeys): callable + { + return function (string $email) use ($monthKeys): array { + return [ + 'month_keys' => $monthKeys, + 'error' => null, + ]; + }; + } + + /** + * Return a fake fetcher callable that always returns an error. + */ + private function fakeFetcherWithError(string $errorMessage): callable + { + return function (string $email) use ($errorMessage): array { + return [ + 'month_keys' => [], + 'error' => $errorMessage, + ]; + }; + } +} diff --git a/tests/MembershipStandingTest.php b/tests/MembershipStandingTest.php new file mode 100644 index 0000000..f347119 --- /dev/null +++ b/tests/MembershipStandingTest.php @@ -0,0 +1,225 @@ +assertSame(2026 * 12 + 4, month_key_to_index('2026-04')); + } + + public function test_month_key_to_index_january() + { + $this->assertSame(2026 * 12 + 1, month_key_to_index('2026-01')); + } + + public function test_month_key_to_index_december() + { + $this->assertSame(2025 * 12 + 12, month_key_to_index('2025-12')); + } + + // --- count_missed_completed_months --- + + public function test_count_missed_zero_when_paid_last_month() + { + // As-of April, last paid March: last completed month is March, missed = 0 + $this->assertSame(0, count_missed_completed_months('2026-03', '2026-04')); + } + + public function test_count_missed_one_when_paid_two_months_ago() + { + // As-of April, last paid February: missed March = 1 + $this->assertSame(1, count_missed_completed_months('2026-02', '2026-04')); + } + + public function test_count_missed_two_when_paid_three_months_ago() + { + // As-of April, last paid January: missed Feb, Mar = 2 + $this->assertSame(2, count_missed_completed_months('2026-01', '2026-04')); + } + + public function test_count_missed_three() + { + // As-of April, last paid December: missed Jan, Feb, Mar = 3 + $this->assertSame(3, count_missed_completed_months('2025-12', '2026-04')); + } + + public function test_count_missed_six() + { + // As-of April, last paid September: missed Oct, Nov, Dec, Jan, Feb, Mar = 6 + $this->assertSame(6, count_missed_completed_months('2025-09', '2026-04')); + } + + public function test_count_missed_seven() + { + // As-of April, last paid August: missed Sep..Mar = 7 + $this->assertSame(7, count_missed_completed_months('2025-08', '2026-04')); + } + + public function test_count_missed_year_boundary() + { + // As-of March 2026, last paid November 2025: missed Dec, Jan, Feb = 3 + $this->assertSame(3, count_missed_completed_months('2025-11', '2026-03')); + } + + /** + * When last paid equals the current (in-progress) month, missed = 0. + * + * The calculation is max(0, as_of_index - 1 - as_of_index) = max(0, -1) = 0. + * This clamp is load-bearing for the new-member exception path: without it, + * a first-time payment in the current month would produce a negative count. + */ + public function test_count_missed_clamped_to_zero_when_last_paid_is_current_month() + { + $this->assertSame(0, count_missed_completed_months('2026-04', '2026-04')); + } + + // --- last_paid_month_before --- + + public function test_last_paid_before_current_month() + { + $this->assertSame('2026-03', last_paid_month_before(['2026-02', '2026-03'], '2026-04')); + } + + public function test_last_paid_excludes_current_month() + { + // Current month payment should NOT be returned as last_paid + $this->assertSame('2026-03', last_paid_month_before(['2026-03', '2026-04'], '2026-04')); + } + + public function test_last_paid_returns_null_when_no_prior_payments() + { + // Only current month payment + $this->assertNull(last_paid_month_before(['2026-04'], '2026-04')); + } + + public function test_last_paid_returns_null_for_empty_history() + { + $this->assertNull(last_paid_month_before([], '2026-04')); + } + + public function test_last_paid_uses_most_recent_prior_month() + { + $this->assertSame('2026-02', last_paid_month_before(['2025-06', '2026-02'], '2026-04')); + } + + // --- classify_membership_standing --- + + public function test_good_standing_paid_last_month() + { + $result = classify_membership_standing(['2026-03'], '2026-04'); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_good_standing_two_missed_months() + { + // 2 missed = upper boundary of Good band + $result = classify_membership_standing(['2026-01'], '2026-04'); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_early_arrears_three_missed_months() + { + // Missed Jan, Feb, Mar = 3 + $result = classify_membership_standing(['2025-12'], '2026-04'); + $this->assertSame(STANDING_EARLY_ARREARS, $result); + } + + public function test_lapsing_four_missed_months() + { + $result = classify_membership_standing(['2025-11'], '2026-04'); + $this->assertSame(STANDING_LAPSING, $result); + } + + public function test_lapsing_six_missed_months() + { + $result = classify_membership_standing(['2025-09'], '2026-04'); + $this->assertSame(STANDING_LAPSING, $result); + } + + public function test_lapsed_seven_missed_months() + { + $result = classify_membership_standing(['2025-08'], '2026-04'); + $this->assertSame(STANDING_LAPSED, $result); + } + + /** + * Year-boundary case: 7 missed months spanning two calendar years. + * + * Last paid May 2025 → missed Jun, Jul, Aug, Sep, Oct, Nov, Dec 2025 = 7 months. + * As-of January 2026. month_key_to_index must use year × 12 + month for + * the arithmetic to produce the correct count across the year boundary. + */ + public function test_lapsed_seven_missed_months_across_year_boundary() + { + $result = classify_membership_standing(['2025-05'], '2026-01'); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_lapsed_no_payment_history_at_all() + { + $result = classify_membership_standing([], '2026-04'); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_new_member_first_payment_this_month_is_good() + { + // No prior payments, first payment is in the current month + $result = classify_membership_standing(['2026-04'], '2026-04'); + $this->assertSame(STANDING_GOOD, $result); + } + + public function test_lapsed_flag_overrides_good_payment() + { + // Even with a recent payment, lapsed flag wins -- must rejoin + $result = classify_membership_standing(['2026-03'], '2026-04', true); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_lapsed_flag_overrides_current_month_payment() + { + // Lapsed flag wins even over a current-month payment + $result = classify_membership_standing(['2026-04'], '2026-04', true); + $this->assertSame(STANDING_LAPSED, $result); + } + + public function test_year_boundary_three_missed() + { + // As-of March 2026, last paid November 2025: missed Dec, Jan, Feb = 3 + $result = classify_membership_standing(['2025-11'], '2026-03'); + $this->assertSame(STANDING_EARLY_ARREARS, $result); + } + + public function test_multiple_payments_uses_most_recent_for_missed_count() + { + // Most recent prior payment is Feb, missed only March = 1 -> good + $result = classify_membership_standing(['2025-06', '2026-02'], '2026-04'); + $this->assertSame(STANDING_GOOD, $result); + } + + /** + * A current-month payment does NOT immediately reset standing when the + * member has prior payment history. The missed count is still derived from + * the most recent *prior* month's payment. The status resets next month once + * the current month becomes a completed month. + */ + public function test_current_month_payment_does_not_reset_standing_when_prior_history_exists() + { + $fiveMonthsAgo = gmdate('Y-m', mktime(0, 0, 0, (int)gmdate('n') - 6, 1, (int)gmdate('Y'))); + $thisMonth = gmdate('Y-m'); + $result = classify_membership_standing([$fiveMonthsAgo, $thisMonth], $thisMonth); + $this->assertSame(STANDING_LAPSING, $result); + } +} diff --git a/tests/StripePaymentHistoryTest.php b/tests/StripePaymentHistoryTest.php new file mode 100644 index 0000000..9752c24 --- /dev/null +++ b/tests/StripePaymentHistoryTest.php @@ -0,0 +1,690 @@ + (object)['id' => $id], $ids); + return (object)['data' => $data, 'has_more' => false]; + } + + /** + * Build a real \Stripe\Subscription object with a single item. + * + * @param string $id Subscription ID. + * @param string $product_id The product ID on the price (bare string form). + * @return \Stripe\Subscription + */ + private function fakeSubscription(string $id, string $product_id): \Stripe\Subscription + { + return \Stripe\Subscription::constructFrom([ + 'id' => $id, + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_' . $id, + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_test', + 'object' => 'price', + 'product' => $product_id, + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + } + + /** + * Build a fake subscription list response. + * + * @param \Stripe\Subscription[] $subscriptions + * @param bool $has_more + * @return object + */ + private function fakeSubscriptionList(array $subscriptions, bool $has_more = false): object + { + return (object)['data' => $subscriptions, 'has_more' => $has_more]; + } + + /** + * Build a fake Invoice list response. + * + * @param int[] $paid_at_timestamps Unix timestamps for paid invoices. + * @param bool $has_more + * @return object + */ + private function fakeInvoiceList(array $paid_at_timestamps, bool $has_more = false): object + { + $data = array_map(function (int $ts) { + static $i = 0; + $i++; + return (object)[ + 'id' => "in_$i", + 'status_transitions' => (object)['paid_at' => $ts], + ]; + }, $paid_at_timestamps); + return (object)['data' => $data, 'has_more' => $has_more]; + } + + /** + * Build a fake wpdb that returns the given rows from get_results(). + */ + private function fakeWpdb(array $rows): object + { + return new class ($rows) { + public string $options = 'wp_options'; + + public function __construct(private array $rows) + { + } + + public function prepare(string $query): string + { + return $query; + } + + public function esc_like(string $str): string + { + return $str; + } + + public function get_results(string $query, $output): array + { + return $this->rows; + } + }; + } + + /** + * Unix timestamp for a given 'YYYY-MM-DD' string. + */ + private function ts(string $date): int + { + return (int)strtotime($date . 'T12:00:00Z'); + } + + // ------------------------------------------------------------------------- + // get_membership_product_ids + // ------------------------------------------------------------------------- + + public function test_product_ids_empty_when_no_plans() + { + global $wpdb; + $wpdb = $this->fakeWpdb([]); + + $ids = get_membership_product_ids(); + + $this->assertSame([], $ids); + } + + public function test_product_ids_extracted_from_single_plan() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_abc'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_abc'], $ids); + } + + public function test_product_ids_extracted_from_multiple_plans() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_1'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_2'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_1', 'prod_2'], $ids); + } + + public function test_product_ids_deduplicated() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['stripe_product_id' => 'prod_same'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_same'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_same'], $ids); + } + + public function test_product_ids_skips_plans_without_stripe_product_id() + { + global $wpdb; + $wpdb = $this->fakeWpdb([ + ['option_value' => serialize(['label' => 'No Stripe Yet'])], + ['option_value' => serialize(['stripe_product_id' => 'prod_ok'])], + ]); + Functions\expect('maybe_unserialize')->andReturnUsing(fn($v) => unserialize($v)); + + $ids = get_membership_product_ids(); + + $this->assertSame(['prod_ok'], $ids); + } + + // ------------------------------------------------------------------------- + // is_gmtu_subscription + // ------------------------------------------------------------------------- + + public function test_is_gmtu_subscription_matches_bare_product_id() + { + $sub = $this->fakeSubscription('sub_1', 'prod_gmtu'); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_no_match_when_different_product() + { + $sub = $this->fakeSubscription('sub_1', 'prod_other'); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_no_match_with_empty_product_id_list() + { + $sub = $this->fakeSubscription('sub_1', 'prod_gmtu'); + + $this->assertFalse(is_gmtu_subscription($sub, [])); + } + + public function test_is_gmtu_subscription_matches_one_of_multiple_product_ids() + { + $sub = $this->fakeSubscription('sub_1', 'prod_b'); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_a', 'prod_b', 'prod_c'])); + } + + public function test_is_gmtu_subscription_with_no_items() + { + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_empty', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_returns_false_when_product_is_null() + { + // Stripe may return a price without a product (e.g. archived/detached product). + // A null product must never match any configured product ID. + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_null_product', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_1', + 'object' => 'price', + // 'product' key absent → resolves to null + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertFalse(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_with_expanded_product_object() + { + // When the Product is expanded, price->product is a Product object, not a string. + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_expanded', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => [ + 'id' => 'price_1', + 'object' => 'price', + 'product' => [ + 'id' => 'prod_gmtu', + 'object' => 'product', + 'name' => 'Membership: Monthly', + ], + ], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + public function test_is_gmtu_subscription_matches_first_of_multiple_items() + { + $sub = \Stripe\Subscription::constructFrom([ + 'id' => 'sub_multi', + 'object' => 'subscription', + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'price' => ['id' => 'price_1', 'object' => 'price', 'product' => 'prod_other'], + ], + [ + 'id' => 'si_2', + 'object' => 'subscription_item', + 'price' => ['id' => 'price_2', 'object' => 'price', 'product' => 'prod_gmtu'], + ], + ], + 'has_more' => false, + 'url' => '/v1/subscription_items', + ], + ]); + + $this->assertTrue(is_gmtu_subscription($sub, ['prod_gmtu'])); + } + + // ------------------------------------------------------------------------- + // fetch_paid_invoice_timestamps + // ------------------------------------------------------------------------- + + public function test_invoice_timestamps_returned_from_single_page() + { + $ts = $this->ts('2026-01-15'); + $lister = fn($params) => $this->fakeInvoiceList([$ts]); + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertSame([$ts], $result); + } + + public function test_invoice_timestamps_empty_when_no_invoices() + { + $lister = fn($params) => $this->fakeInvoiceList([]); + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertSame([], $result); + } + + public function test_invoice_timestamps_skips_null_paid_at() + { + $invoiceList = (object)[ + 'data' => [ + (object)['id' => 'in_1', 'status_transitions' => (object)['paid_at' => null]], + (object)['id' => 'in_2', 'status_transitions' => (object)['paid_at' => 0]], + (object)['id' => 'in_3', 'status_transitions' => (object)['paid_at' => $this->ts('2026-02-10')]], + ], + 'has_more' => false, + ]; + $lister = fn($params) => $invoiceList; + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertCount(1, $result); + $this->assertSame($this->ts('2026-02-10'), $result[0]); + } + + public function test_invoice_timestamps_paginates_correctly() + { + $ts1 = $this->ts('2026-01-15'); + $ts2 = $this->ts('2026-02-15'); + + $page1 = (object)[ + 'data' => [(object)['id' => 'in_1', 'status_transitions' => (object)['paid_at' => $ts1]]], + 'has_more' => true, + ]; + $page2 = (object)[ + 'data' => [(object)['id' => 'in_2', 'status_transitions' => (object)['paid_at' => $ts2]]], + 'has_more' => false, + ]; + + $calls = 0; + $lister = function ($params) use ($page1, $page2, &$calls) { + $calls++; + return $calls === 1 ? $page1 : $page2; + }; + + $result = fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + $this->assertCount(2, $result); + $this->assertContains($ts1, $result); + $this->assertContains($ts2, $result); + $this->assertSame(2, $calls); + } + + public function test_invoice_pagination_sets_starting_after() + { + $ts1 = $this->ts('2026-01-15'); + $ts2 = $this->ts('2026-02-15'); + + $page1 = (object)[ + 'data' => [(object)['id' => 'in_page1_last', 'status_transitions' => (object)['paid_at' => $ts1]]], + 'has_more' => true, + ]; + $page2 = (object)[ + 'data' => [(object)['id' => 'in_page2', 'status_transitions' => (object)['paid_at' => $ts2]]], + 'has_more' => false, + ]; + + $capturedParams = []; + $calls = 0; + $lister = function ($params) use ($page1, $page2, &$calls, &$capturedParams) { + $capturedParams[] = $params; + $calls++; + return $calls === 1 ? $page1 : $page2; + }; + + fetch_paid_invoice_timestamps('cus_1', 'sub_1', $lister); + + // Second call must include starting_after = last invoice ID of page 1 + $this->assertArrayHasKey('starting_after', $capturedParams[1]); + $this->assertSame('in_page1_last', $capturedParams[1]['starting_after']); + } + + // ------------------------------------------------------------------------- + // fetch_gmtu_payment_months — error paths + // ------------------------------------------------------------------------- + + public function test_returns_error_when_stripe_key_not_configured() + { + unset($_ENV['STRIPE_SECRET_KEY']); + + $result = fetch_gmtu_payment_months('member@example.com'); + + $this->assertSame([], $result['month_keys']); + $this->assertNotNull($result['error']); + $this->assertStringContainsString('Stripe secret key', $result['error']); + } + + public function test_returns_error_when_no_membership_products_configured() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => [], // no product IDs + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNotNull($result['error']); + $this->assertStringContainsString('No membership plans', $result['error']); + } + + public function test_returns_empty_month_keys_when_no_customers_found() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'nobody@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList([]), // no customers + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_error_on_stripe_api_exception() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => throw new \RuntimeException('Connection timeout'), + ); + + $this->assertSame([], $result['month_keys']); + $this->assertSame('Connection timeout', $result['error']); + } + + // ------------------------------------------------------------------------- + // fetch_gmtu_payment_months — main logic + // ------------------------------------------------------------------------- + + public function test_returns_empty_when_no_qualifying_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $nonGmtuSub = $this->fakeSubscription('sub_other', 'prod_different'); + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$nonGmtuSub]), + ); + + $this->assertSame([], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_month_keys_from_paid_invoices() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $ts = $this->ts('2026-01-15'); + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList([$ts]), + ); + + $this->assertSame(['2026-01'], $result['month_keys']); + $this->assertNull($result['error']); + } + + public function test_returns_multiple_month_keys_sorted() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $timestamps = [ + $this->ts('2026-03-10'), + $this->ts('2026-01-05'), + $this->ts('2026-02-20'), + ]; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList($timestamps), + ); + + $this->assertSame(['2026-01', '2026-02', '2026-03'], $result['month_keys']); + } + + public function test_deduplicates_month_keys_from_multiple_invoices_same_month() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $timestamps = [ + $this->ts('2026-01-01'), + $this->ts('2026-01-15'), // same month + $this->ts('2026-01-31'), // same month + ]; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub]), + fn($p) => $this->fakeInvoiceList($timestamps), + ); + + $this->assertSame(['2026-01'], $result['month_keys']); + } + + public function test_aggregates_invoices_across_multiple_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub1 = $this->fakeSubscription('sub_1', 'prod_gmtu'); + $sub2 = $this->fakeSubscription('sub_2', 'prod_gmtu'); + + $call = 0; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$sub1, $sub2]), + function ($p) use (&$call) { + $call++; + $ts = $call === 1 ? $this->ts('2026-01-10') : $this->ts('2026-03-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + $this->assertSame(['2026-01', '2026-03'], $result['month_keys']); + } + + public function test_aggregates_invoices_across_multiple_customers() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + + $customerCall = 0; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1', 'cus_2']), + fn($p) => $this->fakeSubscriptionList([$sub]), + function ($p) use (&$customerCall) { + $customerCall++; + $ts = $customerCall === 1 ? $this->ts('2026-01-10') : $this->ts('2026-02-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + $this->assertSame(['2026-01', '2026-02'], $result['month_keys']); + } + + public function test_skips_non_gmtu_subscriptions_but_collects_gmtu_ones() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $gmtuSub = $this->fakeSubscription('sub_gmtu', 'prod_gmtu'); + $otherSub = $this->fakeSubscription('sub_other', 'prod_other'); + + $invoiceCallIds = []; + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + fn($p) => $this->fakeSubscriptionList([$gmtuSub, $otherSub]), + function ($p) use (&$invoiceCallIds) { + $invoiceCallIds[] = $p['subscription']; + return $this->fakeInvoiceList([$this->ts('2026-01-10')]); + } + ); + + // Invoice lister should only be called for the GMTU subscription. + $this->assertSame(['sub_gmtu'], $invoiceCallIds); + $this->assertSame(['2026-01'], $result['month_keys']); + } + + public function test_paginates_subscriptions() + { + $_ENV['STRIPE_SECRET_KEY'] = 'sk_test_fake'; + + $sub1 = $this->fakeSubscription('sub_1', 'prod_gmtu'); + $sub2 = $this->fakeSubscription('sub_2', 'prod_gmtu'); + + $page1 = (object)['data' => [$sub1], 'has_more' => true]; + $page2 = (object)['data' => [$sub2], 'has_more' => false]; + + $subCall = 0; + $capturedParams = []; + $invoiceCall = 0; + + $result = fetch_gmtu_payment_months( + 'member@example.com', + fn() => ['prod_gmtu'], + fn($p) => $this->fakeCustomerList(['cus_1']), + function ($p) use ($page1, $page2, &$subCall, &$capturedParams) { + $capturedParams[] = $p; + $subCall++; + return $subCall === 1 ? $page1 : $page2; + }, + function ($p) use (&$invoiceCall) { + $invoiceCall++; + $ts = $invoiceCall === 1 ? $this->ts('2026-01-10') : $this->ts('2026-02-10'); + return $this->fakeInvoiceList([$ts]); + } + ); + + // Both subscription pages were fetched. + $this->assertSame(2, $subCall); + // Second page request used starting_after = first subscription's ID. + $this->assertSame('sub_1', $capturedParams[1]['starting_after']); + // Both subscriptions' invoices were collected. + $this->assertSame(['2026-01', '2026-02'], $result['month_keys']); + } + + protected function tear_down(): void + { + unset($_ENV['STRIPE_SECRET_KEY']); + parent::tear_down(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2a02c69..124628b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,6 +8,10 @@ // Composer autoloader (loads Brain Monkey, Mockery, PHPUnit polyfills) require_once dirname(__DIR__) . '/vendor/autoload.php'; +// Stub the parent plugin's Settings class so StripePaymentHistory can call +// Settings::get() without the full WordPress/CarbonFields stack. +require_once __DIR__ . '/stubs/Settings.php'; + // Define WordPress constants the plugin expects if (!defined('ABSPATH')) { define('ABSPATH', '/tmp/wordpress/'); @@ -15,6 +19,9 @@ if (!defined('DAY_IN_SECONDS')) { define('DAY_IN_SECONDS', 86400); } +if (!defined('ARRAY_A')) { + define('ARRAY_A', 'ARRAY_A'); +} // Load all source files in dependency order. require_once dirname(__DIR__) . '/src/Logger.php'; @@ -26,3 +33,7 @@ require_once dirname(__DIR__) . '/src/BranchAssignment.php'; require_once dirname(__DIR__) . '/src/Tagging.php'; require_once dirname(__DIR__) . '/src/Notifications.php'; +require_once dirname(__DIR__) . '/src/MembershipStanding.php'; +require_once dirname(__DIR__) . '/src/LapsedStore.php'; +require_once dirname(__DIR__) . '/src/StripePaymentHistory.php'; +require_once dirname(__DIR__) . '/src/LapsingOverride.php'; diff --git a/tests/stubs/Settings.php b/tests/stubs/Settings.php new file mode 100644 index 0000000..44712bd --- /dev/null +++ b/tests/stubs/Settings.php @@ -0,0 +1,20 @@ +