PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / StripeSubscriptions.php

StripeSubscriptions.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.2, at app/Modules/PaymentMethods/StripeGateway/StripeSubscriptions.php

320 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\App\Helpers\Status;
6 use FluentCart\App\Helpers\StatusHelper;
7 use FluentCart\App\Helpers\CurrenciesHelper;
8 use FluentCart\App\Models\OrderTransaction;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Models\SubscriptionMeta;
11 use FluentCart\App\Modules\PaymentMethods\Core\AbstractSubscriptionModule;
12 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\Framework\Support\Arr;
17
18 class StripeSubscriptions extends AbstractSubscriptionModule
19 {
20 /**
21 * Read-only lookup used by the admin "Edit Vendor IDs" verify action.
22 *
23 * Stripe addresses subscriptions directly; the customer it reports back is what
24 * the admin compares against before saving.
25 */
26 public function verifyVendorSubscription(array $args, $mode = 'current')
27 {
28 $vendorSubscriptionId = Arr::get($args, 'vendor_subscription_id');
29
30 if (!$vendorSubscriptionId) {
31 return new \WP_Error('invalid_subscription', __('A Vendor Subscription ID is required to look up a Stripe subscription.', 'fluent-cart'));
32 }
33
34 $subscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [], $mode);
35
36 if (is_wp_error($subscription)) {
37 return $subscription;
38 }
39
40 $currency = strtoupper((string) Arr::get($subscription, 'currency'));
41 $amount = Arr::get($subscription, 'items.data.0.price.unit_amount');
42
43 if ($amount !== null) {
44 $amount = CurrenciesHelper::isZeroDecimal($currency)
45 ? (string) (int) $amount
46 : number_format(((int) $amount) / 100, 2, '.', '');
47 }
48
49 $nextBilling = Arr::get($subscription, 'current_period_end');
50
51 return [
52 'id' => Arr::get($subscription, 'id'),
53 'status' => Arr::get($subscription, 'status'),
54 'customer_id' => Arr::get($subscription, 'customer'),
55 'amount' => $amount === null ? '' : $amount,
56 'currency' => $currency,
57 'next_billing_date' => $nextBilling ? gmdate('Y-m-d H:i:s', (int) $nextBilling) : '',
58 ];
59 }
60
61 public function reSyncSubscriptionFromRemote(Subscription $subscriptionModel)
62 {
63 if ($subscriptionModel->current_payment_method !== 'stripe') {
64 return new \WP_Error('invalid_payment_method', __('This subscription is not using Stripe as payment method.', 'fluent-cart'));
65 }
66
67 $order = $subscriptionModel->order;
68
69 $vendorSubscriptionId = $subscriptionModel->vendor_subscription_id;
70 if (!$vendorSubscriptionId) {
71 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
72 }
73
74 $stripeSubscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [
75 'expand' => ['latest_invoice', 'default_payment_method']
76 ], $order->mode);
77
78 if (is_wp_error($stripeSubscription)) {
79 return $stripeSubscription;
80 }
81
82 $this->syncActivePaymentMethod($subscriptionModel, $stripeSubscription);
83
84 $invoices = (new API())->getStripeObject('invoices', [
85 'subscription' => $vendorSubscriptionId,
86 'status' => 'paid'
87 ], $order->mode);
88
89 if (is_wp_error($invoices)) {
90 return $invoices;
91 }
92
93 $invoices = Arr::get($invoices, 'data', []);
94
95 $subscriptionUpdateData = StripeHelper::getSubscriptionUpdateData($stripeSubscription, $subscriptionModel);
96
97 // reverse the array to get the latest transaction last
98 $invoices = array_reverse($invoices);
99 $newPayment = false;
100
101 foreach ($invoices as $key => $invoice) {
102 //$invoice is array
103 if (Arr::get($invoice, 'amount_paid') == 0) {
104 continue;
105 }
106
107 $transaction = OrderTransaction::query()
108 ->whereIn('vendor_charge_id', [
109 Arr::get($invoice, 'payment_intent'),
110 Arr::get($invoice, 'charge')
111 ])
112 ->where('transaction_type', 'charge')
113 ->first();
114
115 if (!$transaction) {
116 // check local transactions missing vendor_charge_id
117 $transaction = OrderTransaction::query()
118 ->select(['id', 'order_id'])
119 ->where('subscription_id', $subscriptionModel->id)
120 ->where('vendor_charge_id', '')
121 ->where('transaction_type', 'charge')
122 ->where('total', (int)Arr::get($invoice, 'amount_paid'))
123 ->first();
124
125 if ($transaction) {
126 $transaction->update([
127 'vendor_charge_id' => Arr::get($invoice, 'payment_intent')
128 ]);
129 continue;
130 }
131
132 $amountPaid = Arr::get($invoice, 'amount_paid');
133 $chargeCurrency = Arr::get($invoice, 'currency');
134
135 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
136 $amountPaid = $amountPaid * 100;
137 }
138
139 $transactionData = [
140 'payment_method' => 'stripe',
141 'total' => (int)$amountPaid,
142 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
143 'created_at' => ($paidAt = Arr::get($invoice, 'status_transitions.paid_at')) ? DateTime::anyTimeToGmt($paidAt)->format('Y-m-d H:i:s') : DateTime::now()->format('Y-m-d H:i:s'),
144 ];
145
146 // The remote timestamp is the settlement moment; without it the
147 // model hook would stamp the (much later) resync time.
148 if ($paidAt) {
149 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => DateTime::anyTimeToGmt($paidAt)->format('Y-m-d H:i:s')]);
150 }
151
152 $paymentIntent = (new API())->getStripeObject('payment_intents/' . Arr::get($invoice, 'payment_intent'), ['expand' => ['latest_charge']], $order->mode);
153
154 if (!is_wp_error($paymentIntent) && Arr::get($paymentIntent, 'latest_charge')) {
155 $transactionData['created_at'] = DateTime::anyTimeToGmt(Arr::get($paymentIntent, 'latest_charge.created'))->format('Y-m-d H:i:s');
156 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => $transactionData['created_at']]);
157 $paymentMethodType = Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', '');
158 $transactionData['payment_method_type'] = $paymentMethodType;
159 if ($paymentMethodType === 'sepa_debit') {
160 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.sepa_debit.last4', '');
161 $transactionData['card_brand'] = 'sepa_debit';
162 } else {
163 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
164 $transactionData['card_brand'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
165 }
166 } else {
167 $activePaymentMethod = $subscriptionModel->getMeta('active_payment_method', []);
168 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
169 $activePaymentMethod = [];
170 }
171 if ($activePaymentMethod) {
172 $transactionData['card_last_4'] = Arr::get($activePaymentMethod, 'details.last_4');
173 $transactionData['card_brand'] = Arr::get($activePaymentMethod, 'details.brand');
174 }
175 }
176
177 $newPayment = true;
178 SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
179 } else {
180 // Empty-only, same contract as the model hook: the invoice's
181 // paid_at is the settlement moment, not this resync's run time.
182 $paidAt = Arr::get($invoice, 'status_transitions.paid_at');
183 if ($paidAt && empty($transaction->meta['settled_at'])) {
184 $transaction->meta = array_merge($transaction->meta, [
185 'settled_at' => DateTime::anyTimeToGmt($paidAt)->format('Y-m-d H:i:s')
186 ]);
187 }
188
189 $transaction->update([
190 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
191 'status' => Status::TRANSACTION_SUCCEEDED,
192 'total' => (int)Arr::get($invoice, 'amount_paid')
193 ]);
194
195 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
196 }
197 }
198
199 if (!$newPayment) {
200 $subscriptionModel = SubscriptionService::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateData);
201 } else {
202 $subscriptionModel = Subscription::find($subscriptionModel->id);
203 }
204
205 if ($subscriptionModel->status == Status::SUBSCRIPTION_COMPLETED && $stripeSubscription['status'] === 'active') {
206 $response = (new API)->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], $order->mode);
207
208 if (is_wp_error($response)) {
209 fluent_cart_error_log('Stripe Subscription Deletion Error. Subscription ID: ' . $subscriptionModel->id, $response->get_error_message());
210 }
211 }
212
213 return $subscriptionModel;
214 }
215
216 /**
217 * The card behind the vendor subscription can change outside our card-update
218 * flow (Stripe-hosted portal, dunning card replacement) — such a change fires
219 * customer.subscription.updated, which lands here via reSyncFromRemote. Carry
220 * the remote default payment method into active_payment_method, or the portal
221 * and admin keep rendering the old card.
222 */
223 private function syncActivePaymentMethod(Subscription $subscriptionModel, $stripeSubscription)
224 {
225 $paymentMethod = Arr::get($stripeSubscription, 'default_payment_method');
226
227 if (!is_array($paymentMethod) || !Arr::get($paymentMethod, 'id')) {
228 return;
229 }
230
231 $existing = $subscriptionModel->getMeta('active_payment_method', []) ?: [];
232 // Meta has two shapes in the wild: details.payment_method_id (card-update
233 // flow) and vendor_method_id (confirmation paths) — accept both.
234 $existingMethodId = Arr::get($existing, 'details.payment_method_id') ?: Arr::get($existing, 'vendor_method_id');
235
236 if ($existingMethodId === Arr::get($paymentMethod, 'id')) {
237 return;
238 }
239
240 $value = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethod);
241
242 $rows = SubscriptionMeta::query()
243 ->where('subscription_id', $subscriptionModel->id)
244 ->where('meta_key', 'active_payment_method')
245 ->orderBy('id', 'ASC')
246 ->get();
247
248 if ($rows->isEmpty()) {
249 SubscriptionMeta::query()->create([
250 'subscription_id' => $subscriptionModel->id,
251 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
252 'meta_key' => 'active_payment_method',
253 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
254 'meta_value' => $value,
255 ]);
256
257 $rows = SubscriptionMeta::query()
258 ->where('subscription_id', $subscriptionModel->id)
259 ->where('meta_key', 'active_payment_method')
260 ->orderBy('id', 'ASC')
261 ->get();
262 } else {
263 $rows->first()->update(['meta_value' => $value]);
264 }
265
266 if ($rows->count() > 1) {
267 SubscriptionMeta::query()
268 ->whereIn('id', $rows->slice(1)->pluck('id')->toArray())
269 ->delete();
270 }
271 }
272
273 public function cancel($vendorSubscriptionId, $args = [])
274 {
275 if (!$vendorSubscriptionId) {
276 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
277 }
278
279 // first check if the subscription is already canceled in Stripe
280 $response = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'live'));
281
282 if (is_wp_error($response)) {
283 return $response;
284 }
285
286 $status = StripeHelper::transformSubscriptionStatus($response);
287
288 if ($status == Status::SUBSCRIPTION_CANCELED) {
289 $canceledAt = Arr::get($response, 'canceled_at');
290 return [
291 'status' => Status::SUBSCRIPTION_CANCELED,
292 'canceled_at' => $canceledAt ? gmdate('Y-m-d H:i:s', $canceledAt) : NULL
293 ];
294 }
295
296 $response = (new API())->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'live'));
297
298 if (is_wp_error($response)) {
299 return $response;
300 }
301
302 $canceledAt = Arr::get($response, 'canceled_at');
303
304 return [
305 'status' => StripeHelper::transformSubscriptionStatus($response),
306 'canceled_at' => $canceledAt ? gmdate('Y-m-d H:i:s', $canceledAt) : NULL
307 ];
308 }
309
310 public function cardUpdate($data, $subscriptionId)
311 {
312 (new UpdateCustomerPaymentMethod())->update($data, $subscriptionId);
313 }
314
315 public function switchPaymentMethod($data, $subscriptionId)
316 {
317 (new SwitchCustomerMethod())->switchPayMethod($data, $subscriptionId);
318 }
319 }
320