PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Modules/PaymentMethods/StripeGateway/StripeSubscriptions.php +124 -11 1.3.21 → 1.6.6 View file →
@@ -6,17 +6,59 @@
6 6 use FluentCart\App\Helpers\StatusHelper;
7 7 use FluentCart\App\Helpers\CurrenciesHelper;
8 8 use FluentCart\App\Models\OrderTransaction;
9 9 use FluentCart\App\Models\Subscription;
10 +use FluentCart\App\Models\SubscriptionMeta;
10 11 use FluentCart\App\Modules\PaymentMethods\Core\AbstractSubscriptionModule;
11 12 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
12 13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
13 14 use FluentCart\App\Services\DateTime\DateTime;
15 +use FluentCart\App\Services\Payments\PaymentHelper;
14 16 use FluentCart\Framework\Support\Arr;
15 17
16 18 class StripeSubscriptions extends AbstractSubscriptionModule
17 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');
18 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 +
19 61 public function reSyncSubscriptionFromRemote(Subscription $subscriptionModel)
20 62 {
21 63 if ($subscriptionModel->current_payment_method !== 'stripe') {
22 64 return new \WP_Error('invalid_payment_method', __('This subscription is not using Stripe as payment method.', 'fluent-cart'));
@@ -29,9 +71,9 @@
29 71 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
30 72 }
31 73
32 74 $stripeSubscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [
33 - 'expand' => ['latest_invoice']
75 + 'expand' => ['latest_invoice', 'default_payment_method']
34 76 ], $order->mode);
35 77
36 78 if (is_wp_error($stripeSubscription)) {
37 79 return $stripeSubscription;
@@ -36,8 +78,10 @@
36 78 if (is_wp_error($stripeSubscription)) {
37 79 return $stripeSubscription;
38 80 }
39 81
82 + $this->syncActivePaymentMethod($subscriptionModel, $stripeSubscription);
83 +
40 84 $invoices = (new API())->getStripeObject('invoices', [
41 85 'subscription' => $vendorSubscriptionId,
42 86 'status' => 'paid'
43 87 ], $order->mode);
@@ -68,8 +112,11 @@
68 112 ->where('transaction_type', 'charge')
69 113 ->first();
70 114
71 115 if (!$transaction) {
116 + $chargeCurrency = Arr::get($invoice, 'currency');
117 + $amountPaid = StripeHelper::toInternalAmount(Arr::get($invoice, 'amount_paid'), $chargeCurrency);
118 +
72 119 // check local transactions missing vendor_charge_id
73 120 $transaction = OrderTransaction::query()
74 121 ->select(['id', 'order_id'])
75 122 ->where('subscription_id', $subscriptionModel->id)
@@ -74,9 +121,9 @@
74 121 ->select(['id', 'order_id'])
75 122 ->where('subscription_id', $subscriptionModel->id)
76 123 ->where('vendor_charge_id', '')
77 124 ->where('transaction_type', 'charge')
78 - ->where('total', (int)Arr::get($invoice, 'amount_paid'))
125 + ->where('total', $amountPaid)
79 126 ->first();
80 127
81 128 if ($transaction) {
82 129 $transaction->update([
@@ -84,15 +131,8 @@
84 131 ]);
85 132 continue;
86 133 }
87 134
88 - $amountPaid = Arr::get($invoice, 'amount_paid');
89 - $chargeCurrency = Arr::get($invoice, 'currency');
90 -
91 - if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
92 - $amountPaid = $amountPaid * 100;
93 - }
94 -
95 135 $transactionData = [
96 136 'payment_method' => 'stripe',
97 137 'total' => (int)$amountPaid,
98 138 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
@@ -98,12 +138,19 @@
98 138 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
99 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'),
100 140 ];
101 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 +
102 148 $paymentIntent = (new API())->getStripeObject('payment_intents/' . Arr::get($invoice, 'payment_intent'), ['expand' => ['latest_charge']], $order->mode);
103 149
104 150 if (!is_wp_error($paymentIntent) && Arr::get($paymentIntent, 'latest_charge')) {
105 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']]);
106 153 $paymentMethodType = Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', '');
107 154 $transactionData['payment_method_type'] = $paymentMethodType;
108 155 if ($paymentMethodType === 'sepa_debit') {
109 156 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.sepa_debit.last4', '');
@@ -125,12 +172,21 @@
125 172
126 173 $newPayment = true;
127 174 SubscriptionService::recordRenewalPayment($transactionData, $subscriptionModel, $subscriptionUpdateData);
128 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 +
129 185 $transaction->update([
130 186 'vendor_charge_id' => Arr::get($invoice, 'payment_intent'),
131 187 'status' => Status::TRANSACTION_SUCCEEDED,
132 - 'total' => (int)Arr::get($invoice, 'amount_paid')
188 + 'total' => StripeHelper::toInternalAmount(Arr::get($invoice, 'amount_paid'), Arr::get($invoice, 'currency'))
133 189 ]);
134 190
135 191 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
136 192 }
@@ -152,8 +208,65 @@
152 208
153 209 return $subscriptionModel;
154 210 }
155 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 +
156 269 public function cancel($vendorSubscriptionId, $args = [])
157 270 {
158 271 if (!$vendorSubscriptionId) {
159 272 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
@@ -159,9 +272,9 @@
159 272 return new \WP_Error('invalid_subscription', __('Invalid vendor subscription ID.', 'fluent-cart'));
160 273 }
161 274
162 275 // first check if the subscription is already canceled in Stripe
163 - $response = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'live'));
276 + $response = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [], Arr::get($args, 'mode', 'current'));
164 277
165 278 if (is_wp_error($response)) {
166 279 return $response;
167 280 }