| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\PaymentMethods\StripeGateway; |
| 4 |
|
| 5 |
use FluentCart\App\Helpers\CurrenciesHelper; |
| 6 |
use FluentCart\App\Helpers\Status; |
| 7 |
use FluentCart\App\Models\Customer; |
| 8 |
use FluentCart\App\Models\OrderTransaction; |
| 9 |
use FluentCart\App\Models\Order; |
| 10 |
use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API; |
| 11 |
use FluentCart\App\Services\Payments\PaymentHelper; |
| 12 |
use FluentCart\Api\StoreSettings; |
| 13 |
use FluentCart\App\App; |
| 14 |
use FluentCart\Framework\Support\Arr; |
| 15 |
|
| 16 |
class StripeHelper |
| 17 |
{ |
| 18 |
public static function createOrGetStripeCustomer(Customer $customer) |
| 19 |
{ |
| 20 |
// check if we already have a stripe_customer_id for this person |
| 21 |
$existingStripeCustomerId = $customer->getMeta('stripe_customer_id', false); |
| 22 |
if ($existingStripeCustomerId) { |
| 23 |
$existingStripeCustomer = (new API())->getStripeObject('customers/' . $existingStripeCustomerId); |
| 24 |
|
| 25 |
if (!is_wp_error($existingStripeCustomer) && is_array($existingStripeCustomer) && !empty($existingStripeCustomer['id']) && isset($existingStripeCustomer['email']) && $existingStripeCustomer['email'] === $customer->email) { |
| 26 |
return $existingStripeCustomer; |
| 27 |
} |
| 28 |
} |
| 29 |
|
| 30 |
$customerInfo = array_filter([ |
| 31 |
'name' => $customer->full_name, |
| 32 |
'email' => $customer->email, |
| 33 |
'phone' => $customer->phone ?? '', |
| 34 |
'address' => array_filter([ |
| 35 |
'city' => $customer->city ?? '', |
| 36 |
'country' => $customer->country ?? '', |
| 37 |
'postal_code' => $customer->postcode ?? '', |
| 38 |
'state' => $customer->state ?? '', |
| 39 |
]) |
| 40 |
]); |
| 41 |
|
| 42 |
$newStripeCustomer = (new API())->createStripeObject('customers', $customerInfo); |
| 43 |
|
| 44 |
if (is_wp_error($newStripeCustomer)) { |
| 45 |
return $newStripeCustomer; |
| 46 |
} |
| 47 |
|
| 48 |
$id = Arr::get($newStripeCustomer, 'id', false); |
| 49 |
|
| 50 |
if ($id) { |
| 51 |
$customer->updateMeta('stripe_customer_id', $id); |
| 52 |
} |
| 53 |
|
| 54 |
return $newStripeCustomer; |
| 55 |
} |
| 56 |
|
| 57 |
public static function transformSubscriptionStatus($stripeSubscription, $subscriptionModel = null) |
| 58 |
{ |
| 59 |
$status = strtolower($stripeSubscription['status']); |
| 60 |
|
| 61 |
if ($status === 'active') { |
| 62 |
$status = Status::SUBSCRIPTION_ACTIVE; |
| 63 |
} else if ($status === 'incomplete' || $status === 'incomplete_expired') { |
| 64 |
$status = Status::SUBSCRIPTION_INTENDED; |
| 65 |
} else if ($status === 'trialing') { |
| 66 |
$status = Status::SUBSCRIPTION_TRIALING; |
| 67 |
} else if ($status === 'canceled') { |
| 68 |
$status = Status::SUBSCRIPTION_CANCELED; |
| 69 |
if (Arr::get($stripeSubscription, 'cancellation_details.reason', '') === 'payment_failed') { |
| 70 |
$status = Status::SUBSCRIPTION_EXPIRED; |
| 71 |
} |
| 72 |
} else if ($status === 'unpaid') { |
| 73 |
$status = Status::SUBSCRIPTION_EXPIRED; |
| 74 |
} else if ($status === 'paused') { |
| 75 |
$status = Status::SUBSCRIPTION_PAUSED; |
| 76 |
} else if ($status === 'past_due') { |
| 77 |
$status = Status::SUBSCRIPTION_EXPIRING; |
| 78 |
if ($subscriptionModel && $subscriptionModel->status === 'expired') { |
| 79 |
$status = Status::SUBSCRIPTION_EXPIRED; |
| 80 |
} |
| 81 |
} |
| 82 |
|
| 83 |
return $status; |
| 84 |
} |
| 85 |
|
| 86 |
public static function getSubscriptionUpdateData($stripeSubscription, $subscriptionModel = null) |
| 87 |
{ |
| 88 |
$stripeStatus = strtolower(Arr::get($stripeSubscription, 'status', '')); |
| 89 |
|
| 90 |
$status = self::transformSubscriptionStatus($stripeSubscription, $subscriptionModel); |
| 91 |
|
| 92 |
$amount = Arr::get($stripeSubscription, 'plan.amount', 0); |
| 93 |
$currency = Arr::get($stripeSubscription, 'plan.currency', null); |
| 94 |
|
| 95 |
if ($currency && CurrenciesHelper::isZeroDecimal($currency)) { |
| 96 |
$amount = $amount * 100; |
| 97 |
} |
| 98 |
|
| 99 |
$subscriptionUpdateData = array_filter([ |
| 100 |
'current_payment_method' => 'stripe', |
| 101 |
'status' => $status, |
| 102 |
'recurring_total' => $amount, |
| 103 |
]); |
| 104 |
|
| 105 |
if ($stripeStatus == Status::SUBSCRIPTION_CANCELED) { |
| 106 |
$cancelledAt = (int)Arr::get($stripeSubscription, 'canceled_at'); |
| 107 |
if ($cancelledAt) { |
| 108 |
$subscriptionUpdateData['canceled_at'] = gmdate('Y-m-d H:i:s', $cancelledAt); |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
$currentPeriodEnds = (int)Arr::get($stripeSubscription, 'current_period_end'); |
| 113 |
// we have to check if the last invoice is paid or not! |
| 114 |
// If not paid, then we have to use the $stripeSubscription['current_period_start'] |
| 115 |
$latestInvoice = Arr::get($stripeSubscription, 'latest_invoice', null); |
| 116 |
if ($latestInvoice && !empty($latestInvoice['id'])) { |
| 117 |
// we have the latest invoice |
| 118 |
if (Arr::get($latestInvoice, 'status') !== 'paid') { |
| 119 |
$currentPeriodEnds = (int)Arr::get($stripeSubscription, 'current_period_start'); |
| 120 |
} |
| 121 |
} |
| 122 |
|
| 123 |
if ($currentPeriodEnds) { |
| 124 |
$subscriptionUpdateData['next_billing_date'] = gmdate('Y-m-d H:i:s', $currentPeriodEnds); |
| 125 |
} |
| 126 |
|
| 127 |
return $subscriptionUpdateData; |
| 128 |
} |
| 129 |
|
| 130 |
|
| 131 |
/** |
| 132 |
* Convert a Stripe wire amount (invoice/charge/payment_intent) to FluentCart's |
| 133 |
* internal ×100 unit. Zero-decimal currencies (JPY, KRW, ...) come off the wire |
| 134 |
* unscaled, so they need the ×100 that non-zero-decimal currencies already have. |
| 135 |
* |
| 136 |
* @param int|float $amount |
| 137 |
* @param string $currency |
| 138 |
* @return int |
| 139 |
*/ |
| 140 |
public static function toInternalAmount($amount, $currency) |
| 141 |
{ |
| 142 |
$amount = (int)$amount; |
| 143 |
|
| 144 |
if ($currency && CurrenciesHelper::isZeroDecimal($currency)) { |
| 145 |
$amount *= 100; |
| 146 |
} |
| 147 |
|
| 148 |
return $amount; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Convert a Stripe object's `livemode` flag to a getStripeObject()/getApiKey() |
| 153 |
* `$mode` argument. The store's global mode toggle ('current') is not a reliable |
| 154 |
* proxy for which key an already-fetched object needs — use its own livemode |
| 155 |
* whenever one is available instead of guessing from the store setting. |
| 156 |
* |
| 157 |
* @param bool|null $livemode Null when no prior object/event is available yet. |
| 158 |
* @return string 'live', 'test', or 'current' |
| 159 |
*/ |
| 160 |
public static function modeFromLivemode($livemode) |
| 161 |
{ |
| 162 |
if (is_null($livemode)) { |
| 163 |
return 'current'; |
| 164 |
} |
| 165 |
|
| 166 |
return $livemode ? 'live' : 'test'; |
| 167 |
} |
| 168 |
|
| 169 |
public static function processRemoteRefund($transaction, $amount, $args) |
| 170 |
{ |
| 171 |
$intentId = $transaction->vendor_charge_id; |
| 172 |
if (!$intentId) { |
| 173 |
return new \WP_Error('invalid_refund', __('Invalid transaction ID for refund.', 'fluent-cart')); |
| 174 |
} |
| 175 |
|
| 176 |
$refundAmount = (int)$amount; |
| 177 |
$refundCurrency = $transaction->currency; |
| 178 |
|
| 179 |
if ($refundCurrency && CurrenciesHelper::isZeroDecimal($refundCurrency)) { |
| 180 |
$refundAmount = (int)($refundAmount / 100); |
| 181 |
} |
| 182 |
|
| 183 |
$refundData = [ |
| 184 |
'payment_intent' => $intentId, |
| 185 |
'amount' => $refundAmount, |
| 186 |
]; |
| 187 |
|
| 188 |
$reason = Arr::get($args, 'reason', ''); |
| 189 |
|
| 190 |
if ($reason && in_array($reason, ['duplicate', 'fraudulent', 'requested_by_customer'])) { |
| 191 |
$refundData['reason'] = $reason; |
| 192 |
} |
| 193 |
|
| 194 |
$refunded = (new API())->createStripeObject('refunds', $refundData, $transaction->payment_mode); |
| 195 |
|
| 196 |
if (is_wp_error($refunded)) { |
| 197 |
return $refunded; |
| 198 |
} |
| 199 |
|
| 200 |
$status = Arr::get($refunded, 'status'); |
| 201 |
$acceptedStatus = ['succeeded', 'pending']; |
| 202 |
if (!in_array($status, $acceptedStatus)) { |
| 203 |
return new \WP_Error('refund_failed', __('Refund could not be processed in stripe. Please check on your stripe account', 'fluent-cart')); |
| 204 |
} |
| 205 |
|
| 206 |
return Arr::get($refunded, 'id'); |
| 207 |
} |
| 208 |
|
| 209 |
public static function createOrUpdateIpnRefund($refundData, $parentTransaction) |
| 210 |
{ |
| 211 |
$allRefunds = OrderTransaction::query() |
| 212 |
->where('order_id', $refundData['order_id']) |
| 213 |
->where('transaction_type', Status::TRANSACTION_TYPE_REFUND) |
| 214 |
->orderBy('id', 'DESC') |
| 215 |
->get(); |
| 216 |
|
| 217 |
if ($allRefunds->isEmpty()) { |
| 218 |
// this is the first refund for this order |
| 219 |
return OrderTransaction::query()->create($refundData); |
| 220 |
} |
| 221 |
|
| 222 |
$currentRefundTransactionId = Arr::get($refundData, 'meta.parent_id', ''); |
| 223 |
|
| 224 |
$existingLocalRefund = null; |
| 225 |
foreach ($allRefunds as $refund) { |
| 226 |
if ($refund->vendor_charge_id == $refundData['vendor_charge_id']) { |
| 227 |
if ($refund->total != $refundData['total']) { |
| 228 |
$refund->fill($refundData); |
| 229 |
$refund->save(); |
| 230 |
} |
| 231 |
// this refund already exists |
| 232 |
return $refund; |
| 233 |
} |
| 234 |
|
| 235 |
if (!$refund->vendor_charge_id) { // this is a local redfund without vendor charge id |
| 236 |
$refundTransactionId = Arr::get($refund->meta, 'parent_id', ''); |
| 237 |
$isTransactionMatched = $refundTransactionId == $currentRefundTransactionId; |
| 238 |
|
| 239 |
// this is a local refund without vendor charge id, we will update it |
| 240 |
if ($refund->total == $refundData['total'] && $isTransactionMatched) { |
| 241 |
// this refund already exists |
| 242 |
$existingLocalRefund = $refund; |
| 243 |
} |
| 244 |
} |
| 245 |
} |
| 246 |
|
| 247 |
if ($existingLocalRefund) { |
| 248 |
$existingLocalRefund->fill($refundData); |
| 249 |
$existingLocalRefund->save(); |
| 250 |
return $existingLocalRefund; |
| 251 |
} |
| 252 |
|
| 253 |
$createdRefund = OrderTransaction::query()->create($refundData); |
| 254 |
|
| 255 |
PaymentHelper::updateTransactionRefundedTotal($parentTransaction, $createdRefund->total); |
| 256 |
return $createdRefund; |
| 257 |
} |
| 258 |
|
| 259 |
/* |
| 260 |
* To validate by session, id |
| 261 |
* |
| 262 |
*/ |
| 263 |
public static function validateBySession($id) |
| 264 |
{ |
| 265 |
$apiKey = (new StripeSettingsBase())->getApiKey(); |
| 266 |
|
| 267 |
$session = (new API())->makeRequest('checkout/sessions/' . $id, [], $apiKey, 'GET'); |
| 268 |
|
| 269 |
if (!$session || is_wp_error($session)) { |
| 270 |
return null; |
| 271 |
} |
| 272 |
|
| 273 |
|
| 274 |
$order = Order::query() |
| 275 |
->where('uuid', Arr::get($session, 'client_reference_id')) |
| 276 |
->first(); |
| 277 |
|
| 278 |
if (!$order) { |
| 279 |
return null; |
| 280 |
} |
| 281 |
|
| 282 |
return $order; |
| 283 |
|
| 284 |
} |
| 285 |
|
| 286 |
public static function getCancelUrl(): string |
| 287 |
{ |
| 288 |
$checkoutPage = (new StoreSettings())->getCheckoutPage(); |
| 289 |
// get cart hash from url |
| 290 |
$cartHash = App::request()->get('fct_cart_hash', ''); |
| 291 |
if ($cartHash) { |
| 292 |
return add_query_arg([ |
| 293 |
'fct_cart_hash' => $cartHash |
| 294 |
], $checkoutPage); |
| 295 |
} |
| 296 |
return $checkoutPage; |
| 297 |
} |
| 298 |
|
| 299 |
} |
| 300 |
|