Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions packages/join-block/join.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* Plugin Name: Common Knowledge Join Flow
* Description: Common Knowledge join flow plugin.
* Version: 1.4.28
* Version: 1.4.29
* Author: Common Knowledge <hello@commonknowledge.coop>
* Text Domain: common-knowledge-join-flow
* License: GPLv2 or later
Expand Down Expand Up @@ -396,7 +396,12 @@
// Make stage = "confirm" to match the normal flow
// (the user is redirected back from GoCardless and submits their data to the /join endpoint)
$data['stage'] = "confirm";
update_option("JOIN_FORM_UNPROCESSED_GOCARDLESS_REQUEST_{$billingRequest['id']}", wp_json_encode($data));
// autoload disabled: full join payload, see the Stripe equivalent below.
update_option(
"JOIN_FORM_UNPROCESSED_GOCARDLESS_REQUEST_{$billingRequest['id']}",
wp_json_encode($data),
false
);

return ["href" => $authLink, "gcBillingRequestId" => $billingRequest["id"]];
}
Expand Down Expand Up @@ -458,7 +463,12 @@
// Make stage = "confirm" to match the normal flow
// (the user is redirected back from ChargeBee and submits their data to the /join endpoint)
$data['stage'] = "confirm";
update_option("JOIN_FORM_UNPROCESSED_CHARGEBEE_REQUEST_{$hostedPageId}", wp_json_encode($data));
// autoload disabled: full join payload, see the Stripe equivalent below.
update_option(
"JOIN_FORM_UNPROCESSED_CHARGEBEE_REQUEST_{$hostedPageId}",
wp_json_encode($data),
false
);

return ["href" => $hostedPageUrl, "cbHostedPageId" => $hostedPageId];
} catch (\Exception $e) {
Expand Down Expand Up @@ -509,7 +519,8 @@
$data["customMembershipAmount"] ?? null,
$data["donationAmount"] ?? null,
$data["recurDonation"] ?? false,
!empty($data["donationSupporterMode"])
!empty($data["donationSupporterMode"]),
StripeService::buildSubscriptionIdempotencyKey($data, $plan)
);

// Save this data in the database so if the user pays but never returns to
Expand All @@ -521,7 +532,14 @@
// Make stage = "confirm" to match the normal flow
// (the user returns from the payment step and submits their data to the /join endpoint)
$data['stage'] = "confirm";
update_option("JOIN_FORM_UNPROCESSED_STRIPE_REQUEST_{$subscription->id}", wp_json_encode($data));
// autoload disabled: this holds the full join payload, and a
// backlog of unprocessed requests would otherwise be read into
// memory on every page request.
update_option(
"JOIN_FORM_UNPROCESSED_STRIPE_REQUEST_{$subscription->id}",
wp_json_encode($data),
false
);

return $subscription;
} catch (\Exception $e) {
Expand Down Expand Up @@ -747,6 +765,8 @@
continue;
}

// Replay: complete the join without touching payment state.
$data['isRecoveryReplay'] = true;
JoinService::handleJoin($data);
delete_option($result->option_name);
$joinBlockLog->info("ensureSubscriptionsCreated: success, deleting option {$result->option_name}");
Expand All @@ -769,6 +789,83 @@
wp_schedule_event(time(), 'hourly', 'ck_join_block_stripe_cron_hook');
}

// Finish CRM pushes for members whose payment succeeded but whose CRM record
// was never written. Complements the reconciliation cron below, which can spot
// such a member but holds no copy of their details to fix it with. Reads Stripe
// only to confirm the payment still stands; writes exclusively to CRMs.
add_action('ck_join_block_crm_retry_cron_hook', function () {
JoinService::ensureCrmPushesCompleted();
});

if (!wp_next_scheduled('ck_join_block_crm_retry_cron_hook')) {
wp_schedule_event(time(), 'hourly', 'ck_join_block_crm_retry_cron_hook');
}

// Level-triggered safety net: converge Action Network state with Stripe for
// recent subscriptions, catching webhooks that were missed, suppressed or
// arrived out of order. Alerts (error-level logs, which reach Sentry)
// whenever it finds a divergence it cannot fix itself.
add_action('ck_join_block_reconciliation_cron_hook', function () {
StripeService::initialise();
StripeService::reconcileRecentMemberships();
});

if (!wp_next_scheduled('ck_join_block_reconciliation_cron_hook')) {
wp_schedule_event(time(), 'daily', 'ck_join_block_reconciliation_cron_hook');
}

// Heartbeat for the downstream CRM pipeline (Action Network -> Make ->
// Airtable, etc.), which the join flow cannot observe directly. Completed
// joins are recorded on success and a daily digest is logged and emailed, so
// a silent downstream outage shows up within a day instead of surfacing weeks
// later as missing records.
add_action('ck_join_flow_success', function ($data) {
$joins = get_option('CK_JOIN_FLOW_COMPLETED_JOINS', []);
if (!is_array($joins)) {
$joins = [];
}
$joins[] = [
'email' => $data['email'] ?? '(unknown)',
'time' => gmdate('c'),
];
$cutoff = time() - 30 * 86400;
$joins = array_values(array_filter($joins, function ($join) use ($cutoff) {
return strtotime($join['time'] ?? '') >= $cutoff;
}));
update_option('CK_JOIN_FLOW_COMPLETED_JOINS', $joins, false);
});

add_action('ck_join_block_heartbeat_cron_hook', function () {
global $joinBlockLog;

$joins = get_option('CK_JOIN_FLOW_COMPLETED_JOINS', []);
$dayAgo = time() - 86400;
$recent = array_values(array_filter(is_array($joins) ? $joins : [], function ($join) use ($dayAgo) {
return strtotime($join['time'] ?? '') >= $dayAgo;
}));

$count = count($recent);
$joinBlockLog->info("Join heartbeat: $count completed join(s) in the last 24 hours");

$recipient = apply_filters('ck_join_flow_heartbeat_recipient', get_option('admin_email'));
if (!$recipient) {
return;
}

$lines = array_map(function ($join) {
return ($join['time'] ?? '(unknown time)') . ' ' . ($join['email'] ?? '(unknown)');
}, $recent);
$body = "Completed joins in the last 24 hours: $count\n\n"
. ($lines ? implode("\n", $lines) . "\n\n" : "")
. "Compare against the CRM pipeline (e.g. Action Network -> Make -> Airtable). "
. "If joins are listed here but missing downstream, the pipeline is broken.\n";
wp_mail($recipient, "[CK Join Flow] Daily join heartbeat: $count join(s)", $body);
});

if (!wp_next_scheduled('ck_join_block_heartbeat_cron_hook')) {
wp_schedule_event(time(), 'daily', 'ck_join_block_heartbeat_cron_hook');
}

if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('join update_an_from_stripe', function () {
StripeService::initialise();
Expand Down
4 changes: 3 additions & 1 deletion packages/join-block/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: membership, subscription, join
Contributors: commonknowledgecoop
Requires at least: 5.4
Tested up to: 6.8
Stable tag: 1.4.28
Stable tag: 1.4.29
Requires PHP: 8.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Expand Down Expand Up @@ -107,6 +107,8 @@ Need help? Contact us at [hello@commonknowledge.coop](mailto:hello@commonknowled

== Changelog ==

= 1.4.29 =
* Improve push-to-CRM robustness (add retry queue)
= 1.4.28 =
* Add incomplete stripe journey handling
= 1.4.27 =
Expand Down
36 changes: 36 additions & 0 deletions packages/join-block/src/Services/ActionNetworkService.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ public static function personExists($email)
return self::getPerson($email) !== null;
}

/**
* Read-only view of a person for reconciliation. Returns null if the
* person does not exist, otherwise:
* 'email_status' — the primary email address status (e.g. 'subscribed',
* 'bouncing'), or null if unavailable;
* 'tags' — the person's tag names, or null when enumeration was
* aborted (treat as unknown, not as zero tags);
* 'has_name' — whether the person has a given or family name. The
* join flow always collects a name, while newsletter and
* petition signups create a person from an email alone,
* so this distinguishes "joined" from "merely exists".
*/
public static function getPersonSnapshot($email)
{
$person = self::getPerson($email);
if ($person === null) {
return null;
}

$emailStatus = null;
foreach ($person['email_addresses'] ?? [] as $address) {
if (!empty($address['primary'])) {
$emailStatus = $address['status'] ?? null;
break;
}
}

$tagNames = self::getPersonTagNames($email);

return [
'email_status' => $emailStatus,
'tags' => is_array($tagNames) ? $tagNames : null,
'has_name' => !empty($person['given_name']) || !empty($person['family_name']),
];
}

private static function getPerson($email)
{
global $joinBlockLog;
Expand Down
Loading
Loading