| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\PaymentMethods\StripeGateway; |
| 4 |
|
| 5 |
use FluentCart\App\Events\Subscription\SubscriptionActivated; |
| 6 |
use FluentCart\App\Helpers\Status; |
| 7 |
use FluentCart\App\Models\OrderTransaction; |
| 8 |
use FluentCart\App\Models\Subscription; |
| 9 |
use FluentCart\App\Models\SubscriptionMeta; |
| 10 |
use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API; |
| 11 |
use FluentCart\App\Services\DateTime\DateTime; |
| 12 |
use FluentCart\Framework\Support\Arr; |
| 13 |
|
| 14 |
class SubscriptionsManager |
| 15 |
{ |
| 16 |
public function updateSubscriptionStatus($subscriptionId, $status) |
| 17 |
{ |
| 18 |
$subscription = Subscription::query()->where('id', $subscriptionId)->first(); |
| 19 |
if ($subscription) { |
| 20 |
$subscription->status = $status; |
| 21 |
$subscription->save(); |
| 22 |
} |
| 23 |
} |
| 24 |
|
| 25 |
public function validate($vendorChargeId, $data) |
| 26 |
{ |
| 27 |
if ($vendorChargeId !== Arr::get($data, 'vendor_charge_id')) { |
| 28 |
return new \WP_Error('invalid_vendor_charge_id', __('Invalid vendor charge ID.', 'fluent-cart')); |
| 29 |
} |
| 30 |
return true; |
| 31 |
} |
| 32 |
|
| 33 |
|
| 34 |
/** |
| 35 |
* verify payment method via SetupIntent for future off-session payments, fraud prevention, and SCA compliance. |
| 36 |
* @return true | wp_send_json_success | wp_send_json_error |
| 37 |
* @throws \Exception |
| 38 |
*/ |
| 39 |
public static function verifyPaymentMethod($paymentMethodId, $customerId, $offSession = true) |
| 40 |
{ |
| 41 |
$rateLimitCheck = static::checkRateLimit($customerId); |
| 42 |
if (is_wp_error($rateLimitCheck)) { |
| 43 |
static::sendError(__('Too many verification attempts. You can try again tomorrow.', 'fluent-cart'), 429); |
| 44 |
} |
| 45 |
|
| 46 |
// Verify via SetupIntent (for SCA compliance) |
| 47 |
$setupIntent = (new API())->createStripeObject('setup_intents', [ |
| 48 |
'payment_method' => $paymentMethodId, |
| 49 |
'customer' => $customerId, |
| 50 |
'payment_method_types' => ['card'], |
| 51 |
'confirm' => 'true', |
| 52 |
'usage' => 'off_session' |
| 53 |
]); |
| 54 |
|
| 55 |
if (is_wp_error($setupIntent)) { |
| 56 |
static::sendError($setupIntent->get_error_message()); |
| 57 |
} |
| 58 |
|
| 59 |
$status = Arr::get($setupIntent, 'status'); |
| 60 |
|
| 61 |
if ('requires_action' === $status) { |
| 62 |
wp_send_json([ |
| 63 |
'status' => 'requires_action', |
| 64 |
'message' => __('Payment method updated successfully', 'fluent-cart'), |
| 65 |
'client_secret' => Arr::get($setupIntent, 'client_secret'), |
| 66 |
'customer_id' => $customerId, |
| 67 |
], 200); |
| 68 |
} |
| 69 |
|
| 70 |
if ('succeeded' !== $status) { |
| 71 |
wp_send_json([ |
| 72 |
'status' => 'failed', |
| 73 |
'message' => __('Card verification failed', 'fluent-cart') |
| 74 |
], 423); |
| 75 |
} |
| 76 |
return true; |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* Check rate limit for SetupIntent creation to prevent card testing fraud. |
| 81 |
* |
| 82 |
* Rate limit: 3 attempts per day per customer (for subscription card updates) |
| 83 |
* |
| 84 |
* @param string $customerId Stripe customer ID |
| 85 |
* @return bool|\WP_Error Returns true if allowed, WP_Error if rate limited |
| 86 |
*/ |
| 87 |
protected static function checkRateLimit($customerId) |
| 88 |
{ |
| 89 |
$customerDailyLimit = apply_filters('fluent_cart/stripe/setup_intent_rate_limit_customer_daily', 3, $customerId); |
| 90 |
|
| 91 |
$customerDailyKey = 'fct_stripe_setup_intent_rate_daily_' . md5($customerId); |
| 92 |
$customerDailyAttempts = get_transient($customerDailyKey) ?: 0; |
| 93 |
|
| 94 |
if ($customerDailyAttempts >= $customerDailyLimit) { |
| 95 |
return new \WP_Error('rate_limit_exceeded', __('Daily verification limit reached. You can try again tomorrow.', 'fluent-cart')); |
| 96 |
} |
| 97 |
|
| 98 |
set_transient($customerDailyKey, $customerDailyAttempts + 1, DAY_IN_SECONDS); |
| 99 |
|
| 100 |
return true; |
| 101 |
} |
| 102 |
|
| 103 |
public function getRemainingRateLimit($customerId) |
| 104 |
{ |
| 105 |
$customerDailyLimit = apply_filters('fluent_cart/stripe/setup_intent_rate_limit_customer_daily', 3, $customerId); |
| 106 |
$customerDailyKey = 'fct_stripe_setup_intent_rate_daily_' . md5($customerId); |
| 107 |
$customerDailyAttempts = get_transient($customerDailyKey) ?: 0; |
| 108 |
return $customerDailyLimit - $customerDailyAttempts; |
| 109 |
} |
| 110 |
|
| 111 |
public function getOrCreateStripeCustomer($pm) |
| 112 |
{ |
| 113 |
$api = new API(); |
| 114 |
// check if customer exist with the email, currently not using |
| 115 |
$email = Arr::get($pm, 'billing_details.email'); |
| 116 |
$customers = $api->getStripeObject('customers', [ |
| 117 |
'email' => $email, 'limit' => 1 |
| 118 |
]); |
| 119 |
|
| 120 |
if ($customers && !is_wp_error($customers) && !empty($customers['data'][0])) { |
| 121 |
return Arr::get($customers, 'data.0.id'); |
| 122 |
} |
| 123 |
|
| 124 |
$response = $api->createStripeObject('customers', [ |
| 125 |
'name' => Arr::get($pm, 'billing_details.name'), |
| 126 |
'email' => Arr::get($pm, 'billing_details.email'), |
| 127 |
'address' => Arr::get($pm, 'billing_details.address'), |
| 128 |
]); |
| 129 |
|
| 130 |
if (is_wp_error($response)) { |
| 131 |
static::sendError($response->get_error_message()); |
| 132 |
} |
| 133 |
|
| 134 |
return Arr::get($response, 'id'); |
| 135 |
} |
| 136 |
|
| 137 |
public static function addOldSubscriptionMeta($subscriptionId, $oldSubData) |
| 138 |
{ |
| 139 |
$defaults = [ |
| 140 |
'payment_method' => '', |
| 141 |
'vendor_subscription_id' => '', |
| 142 |
'vendor_customer_id' => '', |
| 143 |
'vendor_plan_id' => '', |
| 144 |
'payment_source' => '', |
| 145 |
'canceled_at' => null, |
| 146 |
'reason' => '', |
| 147 |
'expire_at' => null, |
| 148 |
]; |
| 149 |
|
| 150 |
$oldSubscription = array_merge($defaults, $oldSubData); |
| 151 |
|
| 152 |
// get if exists |
| 153 |
$existingMeta = SubscriptionMeta::query() |
| 154 |
->where('subscription_id', '=', $subscriptionId) |
| 155 |
->where('meta_key', '=', 'old_subscriptions') |
| 156 |
->first(); |
| 157 |
|
| 158 |
$oldSubscriptions = []; |
| 159 |
|
| 160 |
if ($existingMeta && $existingMeta->meta_value) { |
| 161 |
if (is_string($existingMeta->meta_value)) { |
| 162 |
$decoded = json_decode($existingMeta->meta_value, true); |
| 163 |
$oldSubscriptions = is_array($decoded) ? $decoded : []; |
| 164 |
} else { |
| 165 |
$oldSubscriptions = (array)$existingMeta->meta_value; |
| 166 |
} |
| 167 |
} |
| 168 |
|
| 169 |
// Add new subscription data |
| 170 |
$oldSubscriptions[] = $oldSubscription; |
| 171 |
|
| 172 |
// Update or create the meta with JSON encoded value |
| 173 |
SubscriptionMeta::updateOrCreate( |
| 174 |
[ |
| 175 |
'subscription_id' => $subscriptionId, |
| 176 |
//phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key |
| 177 |
'meta_key' => 'old_subscriptions' |
| 178 |
], |
| 179 |
[ |
| 180 |
//phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 181 |
'meta_value' => $oldSubscriptions |
| 182 |
] |
| 183 |
); |
| 184 |
} |
| 185 |
|
| 186 |
public static function sendError($message, $code = 423) |
| 187 |
{ |
| 188 |
wp_send_json([ |
| 189 |
'status' => 'failed', |
| 190 |
'message' => $message |
| 191 |
], $code); |
| 192 |
} |
| 193 |
|
| 194 |
// Used by IPN and Charge Success to confirm subscription after charge succeeded |
| 195 |
public function confirmSubscriptionAfterChargeSucceeded(Subscription $subscription, $billingInfo = []) |
| 196 |
{ |
| 197 |
$order = $subscription->order; |
| 198 |
|
| 199 |
if (!$order) { |
| 200 |
return; |
| 201 |
} |
| 202 |
|
| 203 |
$api = new API(); |
| 204 |
$response = $api->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $order->mode); |
| 205 |
|
| 206 |
if (is_wp_error($response)) { |
| 207 |
return; |
| 208 |
} |
| 209 |
|
| 210 |
$nextBillingDate = Arr::get($response, 'current_period_end') ?? null; |
| 211 |
|
| 212 |
if ($nextBillingDate) { |
| 213 |
$nextBillingDate = gmdate('Y-m-d H:i:s', (int) $nextBillingDate); |
| 214 |
} |
| 215 |
|
| 216 |
$status = StripeHelper::transformSubscriptionStatus($response, $subscription); |
| 217 |
$billCount = OrderTransaction::query()->where('subscription_id', $subscription->id)->count(); |
| 218 |
|
| 219 |
$oldStatus = $subscription->status; |
| 220 |
|
| 221 |
if (Arr::get($response, 'id')) { |
| 222 |
$subscription->next_billing_date = $nextBillingDate; |
| 223 |
$subscription->status = $status; |
| 224 |
$subscription->current_payment_method = 'stripe'; |
| 225 |
$subscription->vendor_subscription_id = Arr::get($response, 'id'); |
| 226 |
$subscription->bill_count = $billCount; |
| 227 |
$subscription->save(); |
| 228 |
} |
| 229 |
|
| 230 |
if ($billingInfo) { |
| 231 |
$subscription->updateMeta('active_payment_method', $billingInfo); |
| 232 |
} |
| 233 |
|
| 234 |
if ($oldStatus != $subscription->status && (Status::SUBSCRIPTION_ACTIVE === $subscription->status || Status::SUBSCRIPTION_TRIALING === $subscription->status)) { |
| 235 |
(new SubscriptionActivated($subscription, $order, $order->customer))->dispatch(); |
| 236 |
} |
| 237 |
|
| 238 |
return $subscription; |
| 239 |
} |
| 240 |
|
| 241 |
} |
| 242 |
|