Skip to content
Merged
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
61 changes: 60 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 25 additions & 3 deletions join-gmtu.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = [
Expand All @@ -75,3 +96,4 @@
register_branch_assignment();
register_tagging();
register_notifications($config);
register_lapsing_override();
82 changes: 82 additions & 0 deletions src/LapsedStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php
/**
* Persistent storage for the GMTU lapsed flag.
*
* Once a member has missed 7 or more completed calendar months, they are
* lapsed. A later payment does not automatically reinstate them -- they
* must rejoin via the join form. This module records that state in WordPress
* options so it survives across webhook calls and page loads.
*
* Storage: wp_options, key = 'gmtu_lapsed_' + SHA-256(lowercased email).
* The value is a JSON object with email, lapsed_at timestamp, and trigger
* name for audit purposes.
*
* The flag is cleared by clear_lapsed() when a member explicitly rejoins
* via the join form (hooked into ck_join_flow_success at priority 5).
*
* @package CommonKnowledge\JoinBlock\Organisation\GMTU
*/

namespace CommonKnowledge\JoinBlock\Organisation\GMTU;

/**
* Generate the WordPress option key for a given email address.
*
* Uses SHA-256 of the lowercased, trimmed email so that keys are a fixed
* length regardless of email length and contain no special characters.
*
* @param string $email Member email address.
* @return string Option name.
*/
function lapsed_option_key(string $email): string
{
return 'gmtu_lapsed_' . hash('sha256', strtolower(trim($email)));
}

/**
* Check whether a member is currently marked as lapsed.
*
* @param string $email Member email address.
* @return bool
*/
function is_lapsed(string $email): bool
{
return (bool) get_option(lapsed_option_key($email), false);
}

/**
* Mark a member as lapsed.
*
* Records the email, timestamp, and the webhook trigger that caused lapsing
* in the option value for audit purposes.
*
* @param string $email Member email address.
* @param string $trigger The webhook trigger (e.g. 'invoice_payment_failed').
* @param string $lapsed_at ISO 8601 timestamp of when lapsing occurred.
* @return void
*/
function mark_lapsed(string $email, string $trigger, string $lapsed_at): void
{
$value = json_encode([
'email' => $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));
}
Loading
Loading