PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.6 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 All 49 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / StripeSubscriptions.php

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

316 lines 13.8 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 $chargeCurrency = Arr::get($invoice, 'currency');
117 $amountPaid = StripeHelper::toInternalAmount(Arr::get($invoice, 'amount_paid'), $chargeCurrency);
118
119 // check local transactions missing vendor_charge_id
120 $transaction = OrderTransaction::query()
121 ->select(['id', 'order_id'])
122 ->where('subscription_id', $subscriptionModel->id)
123 ->where('vendor_charge_id', '')
124 ->where('transaction_type', 'charge')
125 ->where('total', $amountPaid)
126 ->first();
127
128 if ($transaction) {
129 $transaction->update([
130 'vendor_charge_id' => Arr::get($invoice, 'payment_intent')
131 ]);
132 continue;
133 }
134
135 $transactionData = [
136 'payment_method' => 'stripe',
137 'total' => (int)$amountPaid,
138 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
139 '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'),
140 ];
141
142 // The remote timestamp is the settlement moment; without it the
143 // model hook would stamp the (much later) resync time.
144 if ($paidAt) {
145 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => DateTime::anyTimeToGmt($paidAt)->format('Y-m-d H:i:s')]);
146 }
147
148 $paymentIntent = (new API())->getStripeObject('payment_intents/' . Arr::get($invoice, 'payment_intent'), ['expand' => ['latest_charge']], $order->mode);
149
150 if (!is_wp_error($paymentIntent) && Arr::get($paymentIntent, 'latest_charge')) {
151 $transactionData['created_at'] = DateTime::anyTimeToGmt(Arr::get($paymentIntent, 'latest_charge.created'))->format('Y-m-d H:i:s');
152 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => $transactionData['created_at']]);
153 $paymentMethodType = Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', '');
154 $transactionData['payment_method_type'] = $paymentMethodType;
155 if ($paymentMethodType === 'sepa_debit') {
156 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.sepa_debit.last4', '');
157 $transactionData['card_brand'] = 'sepa_debit';
158 } else {
159 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
160 $transactionData['card_brand'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
161 }
162 } else {
163 $activePaymentMethod = $subscriptionModel->getMeta('active_payment_method', []);
164 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
165 $activePaymentMethod = [];
166 }
167 if ($activePaymentMethod) {
168 $transactionData['card_last_4'] = Arr::get($activePaymentMethod, 'details.last_4');
169 $transactionData['card_brand'] = Arr::get($activePaymentMethod, 'details.brand');
170 }
171 }
172
173 $newPayment = true;
174 SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
175 } else {
176 // Empty-only, same contract as the model hook: the invoice's
177 // paid_at is the settlement moment, not this resync's run time.
178 $paidAt = Arr::get($invoice, 'status_transitions.paid_at');
179 if ($paidAt && empty($transaction->meta['settled_at'])) {
180 $transaction->meta = array_merge($transaction->meta, [
181 'settled_at' => DateTime::anyTimeToGmt($paidAt)->format('Y-m-d H:i:s')
182 ]);
183 }
184
185 $transaction->update([
186 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
187 'status' => Status::TRANSACTION_SUCCEEDED,
188 'total' => StripeHelper::toInternalAmount(Arr::get($invoice, 'amount_paid'), Arr::get($invoice, 'currency'))
189 ]);
190
191 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
192 }
193 }
194
195 if (!$newPayment) {
196 $subscriptionModel = SubscriptionService::syncSubscriptionStates($subscriptionModel, $subscriptionUpdateData);
197 } else {
198 $subscriptionModel = Subscription::find($subscriptionModel->id);
199 }
200
201 if ($subscriptionModel->status == Status::SUBSCRIPTION_COMPLETED && $stripeSubscription['status'] === 'active') {
202 $response = (new API)->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], $order->mode);
203
204 if (is_wp_error($response)) {
205 fluent_cart_error_log('Stripe Subscription Deletion Error. Subscription ID: ' . $subscriptionModel->id, $response->get_error_message());
206 }
207 }
208
209 return $subscriptionModel;
210 }
211
212 /**
213 * The card behind the vendor subscription can change outside our card-update
214 * flow (Stripe-hosted portal, dunning card replacement) — such a change fires
215 * customer.subscription.updated, which lands here via reSyncFromRemote. Carry
216 * the remote default payment method into active_payment_method, or the portal
217 * and admin keep rendering the old card.
218 */
219 private function syncActivePaymentMethod(Subscription $subscriptionModel, $stripeSubscription)
220 {
221 $paymentMethod = Arr::get($stripeSubscription, 'default_payment_method');
222
223 if (!is_array($paymentMethod) || !Arr::get($paymentMethod, 'id')) {
224 return;
225 }
226
227 $existing = $subscriptionModel->getMeta('active_payment_method', []) ?: [];
228 // Meta has two shapes in the wild: details.payment_method_id (card-update
229 // flow) and vendor_method_id (confirmation paths) — accept both.
230 $existingMethodId = Arr::get($existing, 'details.payment_method_id') ?: Arr::get($existing, 'vendor_method_id');
231
232 if ($existingMethodId === Arr::get($paymentMethod, 'id')) {
233 return;
234 }
235
236 $value = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethod);
237
238 $rows = SubscriptionMeta::query()
239 ->where('subscription_id', $subscriptionModel->id)
240 ->where('meta_key', 'active_payment_method')
241 ->orderBy('id', 'ASC')
242 ->get();
243
244 if ($rows->isEmpty()) {
245 SubscriptionMeta::query()->create([
246 'subscription_id' => $subscriptionModel->id,
247 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
248 'meta_key' => 'active_payment_method',
249 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
250 'meta_value' => $value,
251 ]);
252
253 $rows = SubscriptionMeta::query()
254 ->where('subscription_id', $subscriptionModel->id)
255 ->where('meta_key', 'active_payment_method')
256 ->orderBy('id', 'ASC')
257 ->get();
258 } else {
259 $rows->first()->update(['meta_value' => $value]);
260 }
261
262 if ($rows->count() > 1) {
263 SubscriptionMeta::query()
264 ->whereIn('id', $rows->slice(1)->pluck('id')->toArray())
265 ->delete();
266 }
267 }
268
269 public function cancel($vendorSubscriptionId, $args = [])
270 {
271 if (!$vendorSubscriptionId) {
272 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
273 }
274
275 // first check if the subscription is already canceled in Stripe
276 $response = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'live'));
277
278 if (is_wp_error($response)) {
279 return $response;
280 }
281
282 $status = StripeHelper::transformSubscriptionStatus($response);
283
284 if ($status == Status::SUBSCRIPTION_CANCELED) {
285 $canceledAt = Arr::get($response, 'canceled_at');
286 return [
287 'status' => Status::SUBSCRIPTION_CANCELED,
288 'canceled_at' => $canceledAt ? gmdate('Y-m-d H:i:s', $canceledAt) : NULL
289 ];
290 }
291
292 $response = (new API())->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'live'));
293
294 if (is_wp_error($response)) {
295 return $response;
296 }
297
298 $canceledAt = Arr::get($response, 'canceled_at');
299
300 return [
301 'status' => StripeHelper::transformSubscriptionStatus($response),
302 'canceled_at' => $canceledAt ? gmdate('Y-m-d H:i:s', $canceledAt) : NULL
303 ];
304 }
305
306 public function cardUpdate($data, $subscriptionId)
307 {
308 (new UpdateCustomerPaymentMethod())->update($data, $subscriptionId);
309 }
310
311 public function switchPaymentMethod($data, $subscriptionId)
312 {
313 (new SwitchCustomerMethod())->switchPayMethod($data, $subscriptionId);
314 }
315 }
316