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/Confirmations.php +677 -69 1.3.21 → 1.6.6 View file →
@@ -11,8 +11,9 @@
11 11 use FluentCart\App\Models\OrderTransaction;
12 12 use FluentCart\App\Models\Subscription;
13 13 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
14 14 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
15 +use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
15 16 use FluentCart\App\Services\DateTime\DateTime;
16 17 use FluentCart\App\Services\Payments\PaymentHelper;
17 18 use FluentCart\Framework\Support\Arr;
18 19
@@ -31,26 +32,375 @@
31 32 return $value;
32 33 }, 10, 2);
33 34
34 35
35 - if (isset($_REQUEST['fct_stripe_hosted']) && isset($_REQUEST['trx_hash'])) {
36 - $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('trx_hash')))->first();
37 - if (!$transaction || $transaction->status === Status::TRANSACTION_SUCCEEDED) {
38 - return;
39 - }
36 + // Browser return from Stripe hosted checkout, dispatched by core
37 + // WebRoutes (?fluent-cart=fct_stripe_hosted): confirm first, then
38 + // send the buyer to the filterable success URL.
39 + add_action('fluent_cart_action_fct_stripe_hosted', [$this, 'handleHostedReturn']);
40 40
41 + // Browser return from an issuer-forced 3DS redirect on an onsite confirm
42 + // (?fluent-cart=fct_stripe_onsite_return), dispatched the same way.
43 + add_action('fluent_cart_action_fct_stripe_onsite_return', [$this, 'handleOnsiteRedirectReturn']);
44 +
45 + }
46 +
47 + /**
48 + * Confirm a hosted-checkout session on the buyer's return, then redirect.
49 + * The gateway return URL is internal and unfiltered; the buyer's real
50 + * destination (fluent_cart/payment/success_url) applies only after
51 + * confirmation has run — so a filter that sends buyers to another page
52 + * can never break payment confirmation.
53 + */
54 + public function handleHostedReturn($requestData)
55 + {
56 + $transaction = OrderTransaction::query()
57 + ->where('uuid', sanitize_text_field(Arr::get($requestData, 'trx_hash', '')))
58 + ->first();
59 +
60 + if (!$transaction) {
61 + wp_redirect(home_url());
62 + exit;
63 + }
64 +
65 + if ($transaction->status !== Status::TRANSACTION_SUCCEEDED) {
41 66 // Get session ID from transaction meta
42 67 $sessionId = Arr::get($transaction->meta, 'session_id');
43 -
68 +
44 69 if ($sessionId) {
45 70 $this->confirmByCheckoutSession($sessionId, $transaction);
46 - } else {
47 - return;
48 71 }
49 - }
72 + }
50 73
74 + // Re-query: confirmByCheckoutSession updates the row, not this instance.
75 + $freshTransaction = OrderTransaction::query()->find($transaction->id);
76 + if ($freshTransaction && $freshTransaction->status === Status::TRANSACTION_SUCCEEDED) {
77 + wp_redirect($this->getHostedReturnRedirectUrl($freshTransaction));
78 + exit;
79 + }
80 +
81 + // Not confirmed (pending, failed, or no session yet): land on the
82 + // receipt page, which renders the order's current state.
83 + wp_redirect($transaction->getReceiptPageUrl());
84 + exit;
51 85 }
52 -
86 +
87 + /**
88 + * Where the buyer lands after a confirmed hosted-checkout return.
89 + */
90 + public function getHostedReturnRedirectUrl($transaction)
91 + {
92 + return $transaction->getSuccessUrl();
93 + }
94 +
95 + /**
96 + * Confirm an onsite payment on the buyer's return from a 3DS redirect.
97 + *
98 + * Onsite confirms with `redirect: 'if_required'`, so the challenge normally
99 + * renders inline and the page never navigates. Some issuers force a full
100 + * redirect to their ACS page instead; Stripe then sends the buyer to the
101 + * return_url with `payment_intent` / `setup_intent` appended. Same contract
102 + * as the hosted return: an internal, unfiltered URL confirms first, and the
103 + * buyer's real destination is applied afterwards.
104 + */
105 + public function handleOnsiteRedirectReturn($requestData)
106 + {
107 + $vendorIntentId = Arr::get($requestData, 'payment_intent');
108 + if (!$vendorIntentId) {
109 + $vendorIntentId = Arr::get($requestData, 'setup_intent');
110 + }
111 +
112 + $trxHash = sanitize_text_field((string) Arr::get($requestData, 'trx_hash', ''));
113 +
114 + $this->confirmRedirectReturn($trxHash, $vendorIntentId);
115 +
116 + $transaction = OrderTransaction::query()->where('uuid', $trxHash)->first();
117 +
118 + if (!$transaction) {
119 + wp_redirect(home_url());
120 + exit;
121 + }
122 +
123 + if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
124 + wp_redirect($this->getHostedReturnRedirectUrl($transaction));
125 + exit;
126 + }
127 +
128 + wp_redirect($transaction->getReceiptPageUrl());
129 + exit;
130 + }
131 +
132 + /**
133 + * Confirm an onsite payment the buyer completed through a 3DS redirect.
134 + *
135 + * Unauthenticated surface: the return URL is a plain GET the buyer's browser
136 + * follows, so nothing here trusts the caller. `redirect_status` is ignored
137 + * entirely — the intent is re-fetched for its authoritative status — and the
138 + * intent must both be shaped like a Stripe id and match the one we stamped on
139 + * the transaction the hash resolves to.
140 + *
141 + * @param string $trxHash
142 + * @param string $vendorIntentId
143 + * @return bool whether the payment was confirmed
144 + */
145 + public function confirmRedirectReturn($trxHash, $vendorIntentId)
146 + {
147 + $trxHash = sanitize_text_field((string) $trxHash);
148 + $vendorIntentId = sanitize_text_field((string) $vendorIntentId);
149 +
150 + if (!$trxHash || !preg_match('/^(pi|seti)_[a-zA-Z0-9_]+$/', $vendorIntentId)) {
151 + return false;
152 + }
153 +
154 + $transaction = OrderTransaction::query()->where('uuid', $trxHash)->first();
155 + if (!$transaction || $this->isSettledTransaction($transaction->status)) {
156 + return false;
157 + }
158 +
159 + if ((string) $transaction->vendor_charge_id !== $vendorIntentId) {
160 + return false;
161 + }
162 +
163 + if (strpos($vendorIntentId, 'seti_') === 0) {
164 + return !is_wp_error($this->confirmSetupIntent($vendorIntentId, $trxHash));
165 + }
166 +
167 + $intent = (new API())->getStripeObject('payment_intents/' . $vendorIntentId, [
168 + 'expand' => ['latest_charge']
169 + ]);
170 +
171 + if (is_wp_error($intent)) {
172 + fluent_cart_add_log(__('Stripe Payment Intent Retrieval Failed', 'fluent-cart'), $intent->get_error_message(), 'error', [
173 + 'module_name' => 'order',
174 + 'module_id' => $transaction->order_id,
175 + ]);
176 + return false;
177 + }
178 +
179 + return $this->applyIntentOutcome($transaction, $vendorIntentId, $intent);
180 + }
181 +
182 + /**
183 + * Record a terminal PaymentIntent outcome against its transaction.
184 + *
185 + * A failed confirm has to land as `failed`, not stay `pending`:
186 + * `CheckoutProcessor` bumps `payment_attempt` only for a failed transaction,
187 + * and without that bump the retry reuses the same idempotency seed and
188 + * replays Stripe's 24h-cached response for a subscription the create-guard
189 + * has since deleted.
190 + *
191 + * @param string $intentId
192 + * @param array $intent
193 + * @param bool $markFailed set false when the caller has not proven the
194 + * reporter owns this transaction
195 + * @return bool
196 + */
197 + protected function applyIntentOutcome(OrderTransaction $transaction, $intentId, $intent, $markFailed = true)
198 + {
199 + // Both entry points are buyer-replayable — the return URL can be revisited
200 + // and the failure report is a nopriv POST — and the caller's model was
201 + // loaded before a Stripe round-trip of hundreds of milliseconds, so a
202 + // refund landing inside that window has to win.
203 + $transaction = OrderTransaction::query()->find($transaction->id);
204 +
205 + if (!$transaction) {
206 + return false;
207 + }
208 +
209 + if ($this->isSettledTransaction($transaction->status)) {
210 + return $transaction->status === Status::TRANSACTION_SUCCEEDED;
211 + }
212 +
213 + $status = Arr::get($intent, 'status');
214 + $failure = $this->intentFailureContext($status, Arr::get($intent, 'last_payment_error', []));
215 +
216 + if (in_array($status, ['requires_payment_method', 'canceled'], true)) {
217 + if ($markFailed) {
218 + $this->markIntentFailed($transaction, $failure);
219 + }
220 +
221 + return false;
222 + }
223 +
224 + // The buyer can still finish this very intent, so leave the transaction
225 + // pending and let them — but record the stall, otherwise an abandoned
226 + // challenge leaves no trace anywhere until Stripe expires the intent.
227 + if (in_array($status, ['requires_action', 'requires_confirmation'], true)) {
228 + $this->logIntentOutcome(
229 + $transaction,
230 + $failure['is_auth_failure']
231 + ? __('Stripe 3D Secure Authentication Not Completed', 'fluent-cart')
232 + : __('Stripe Payment Not Completed', 'fluent-cart'),
233 + $failure['detail'],
234 + 'warning'
235 + );
236 +
237 + return false;
238 + }
239 +
240 + $this->confirmPaymentSuccessByCharge($transaction, [
241 + 'charge' => Arr::get($intent, 'latest_charge', []),
242 + 'intent_id' => $intentId
243 + ]);
244 +
245 + // `processing` and `requires_capture` reach here with a charge that has not
246 + // settled, and confirmPaymentSuccessByCharge leaves those pending. Reporting
247 + // them as confirmed would hand the buyer a receipt redirect for a payment
248 + // nobody has taken, so read back what actually landed.
249 + $settled = OrderTransaction::query()->find($transaction->id);
250 +
251 + return $settled && $settled->status === Status::TRANSACTION_SUCCEEDED;
252 + }
253 +
254 + /**
255 + * Classify a Stripe intent failure and build the line written to the log.
256 + *
257 + * Stripe reports an abandoned or rejected 3DS challenge as
258 + * payment_intent_authentication_failure / setup_intent_authentication_failure /
259 + * authentication_required. It is the single largest cause of a first attempt
260 + * that never completes, so it earns its own title rather than a generic
261 + * decline line.
262 + *
263 + * @param string $status
264 + * @param array $error `last_payment_error` or `last_setup_error`
265 + * @return array{is_auth_failure: bool, detail: string}
266 + */
267 + protected function intentFailureContext($status, $error)
268 + {
269 + if (!is_array($error)) {
270 + $error = [];
271 + }
272 +
273 + $code = (string) Arr::get($error, 'code', '');
274 + $declineCode = (string) Arr::get($error, 'decline_code', '');
275 +
276 + return [
277 + 'is_auth_failure' => strpos($code, 'authentication') !== false
278 + || $declineCode === 'authentication_required',
279 + 'detail' => sprintf(
280 + /* translators: 1: Stripe payment intent status, 2: Stripe error message */
281 + __('Stripe reported the payment intent as %1$s. %2$s', 'fluent-cart'),
282 + $status,
283 + Arr::get($error, 'message', '')
284 + ),
285 + ];
286 + }
287 +
288 + /**
289 + * What actually landed on the row, for the browser's failure report to read.
290 + * It may only re-enable checkout once the transaction is genuinely terminal,
291 + * and the HTTP status cannot say that — a 400 is also how "invalid request"
292 + * and an unfinished challenge answer.
293 + *
294 + * @param OrderTransaction|null $transaction
295 + * @return string
296 + */
297 + protected function reportedTransactionStatus($transaction)
298 + {
299 + if (!$transaction) {
300 + return '';
301 + }
302 +
303 + $fresh = OrderTransaction::query()->find($transaction->id);
304 +
305 + return (string) ($fresh ? $fresh->status : $transaction->status);
306 + }
307 +
308 + /**
309 + * Statuses downstream of a completed payment. Owned by refunds, disputes and
310 + * webhooks — never writable by a confirmation, which can always arrive with a
311 + * charge that still reads `succeeded` at Stripe.
312 + *
313 + * @return array
314 + */
315 + protected function postPaymentStatuses()
316 + {
317 + return [
318 + Status::TRANSACTION_REFUNDED,
319 + Status::TRANSACTION_DISPUTE_LOST,
320 + ];
321 + }
322 +
323 + /**
324 + * Statuses a browser-driven confirm must never rewrite. Adds the two the
325 + * buyer's own replays would otherwise reopen: `succeeded`, and `authorized`
326 + * money Stripe is holding for a later capture.
327 + *
328 + * @return array
329 + */
330 + protected function settledTransactionStatuses()
331 + {
332 + return array_merge([
333 + Status::TRANSACTION_SUCCEEDED,
334 + Status::TRANSACTION_AUTHORIZED,
335 + ], $this->postPaymentStatuses());
336 + }
337 +
338 + /**
339 + * @param string $status
340 + * @return bool
341 + */
342 + protected function isSettledTransaction($status)
343 + {
344 + return in_array((string) $status, $this->settledTransactionStatuses(), true);
345 + }
346 +
347 + /**
348 + * @param array $failure from intentFailureContext()
349 + * @return void
350 + */
351 + protected function markIntentFailed(OrderTransaction $transaction, $failure)
352 + {
353 + // Compare-and-set, not read-then-write: a webhook can settle the row while
354 + // a stale failure report is in flight, and that report must never flip a
355 + // captured, refunded or disputed payment to `failed`. A zero row count also
356 + // covers a repeat report, keeping the log entry below from doubling.
357 + $updated = OrderTransaction::query()
358 + ->where('id', $transaction->id)
359 + ->whereNotIn('status', $this->settledTransactionStatuses())
360 + ->where('status', '!=', Status::TRANSACTION_FAILED)
361 + ->update(['status' => Status::TRANSACTION_FAILED]);
362 +
363 + if (!$updated) {
364 + return;
365 + }
366 +
367 + $transaction->status = Status::TRANSACTION_FAILED;
368 +
369 + $this->logIntentOutcome(
370 + $transaction,
371 + $failure['is_auth_failure']
372 + ? __('Stripe 3D Secure Authentication Failed', 'fluent-cart')
373 + : __('Stripe Payment Failed', 'fluent-cart'),
374 + $failure['detail'],
375 + 'error'
376 + );
377 + }
378 +
379 + /**
380 + * Mirror an intent outcome onto the Order and, when there is one, its Subscription.
381 + *
382 + * @param string $title
383 + * @param string $detail
384 + * @param string $level
385 + * @return void
386 + */
387 + protected function logIntentOutcome(OrderTransaction $transaction, $title, $detail, $level)
388 + {
389 + fluent_cart_add_log($title, $detail, $level, [
390 + 'module_name' => 'order',
391 + 'module_id' => $transaction->order_id,
392 + ]);
393 +
394 + if ($transaction->subscription_id) {
395 + fluent_cart_add_log($title, $detail, $level, [
396 + 'module_type' => 'FluentCart\App\Models\Subscription',
397 + 'module_id' => $transaction->subscription_id,
398 + 'module_name' => 'subscription',
399 + ]);
400 + }
401 + }
402 +
53 403 private function confirmByCheckoutSession($sessionId, $transaction)
54 404 {
55 405
56 406 $api = new API();
@@ -157,9 +507,18 @@
157 507 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
158 508 }
159 509
160 510 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
161 -
511 +
512 + } elseif ($mode === 'setup') {
513 + // Zero-payable system-subscription hosted checkout — no payment_intent
514 + // to confirm, just the vaulted setup_intent. confirmSetupIntent() also
515 + // resolves the transaction by vendor_charge_id, so a stale/mismatched
516 + // session for this transaction is harmless here.
517 + $setupIntentId = Arr::get($session, 'setup_intent');
518 + if ($setupIntentId) {
519 + $this->confirmSetupIntent($setupIntentId);
520 + }
162 521 } else {
163 522 if ($paymentStatus === 'paid') {
164 523 $paymentIntent = Arr::get($session, 'payment_intent');
165 524 if (is_array($paymentIntent)) {
@@ -238,9 +597,23 @@
238 597 $intentId = sanitize_text_field($intentId);
239 598
240 599 // in case of plan change, and first payment is 0, then setup intent will be created
241 600 if (strpos($intentId, 'seti_') === 0) {
242 - $this->confirmSetupIntent($intentId);
601 + $trxHash = sanitize_text_field(App::request()->get('trx_hash'));
602 + if (empty($trxHash)) {
603 + wp_send_json(['message' => __('Invalid request.', 'fluent-cart')], 400);
604 + }
605 + $result = $this->confirmSetupIntent($intentId, $trxHash);
606 + if (is_wp_error($result)) {
607 + wp_send_json(
608 + [
609 + 'message' => $result->get_error_message(),
610 + 'transaction_status' => $this->reportedTransactionStatus(
611 + OrderTransaction::query()->where('uuid', $trxHash)->first()
612 + ),
613 + ], 400
614 + );
615 + }
243 616 wp_send_json(
244 617 [
245 618 'message' => __('Setup intent confirmed successfully. Please check your subscriptions.', 'fluent-cart'),
246 619 ], 200
@@ -271,16 +644,44 @@
271 644 404
272 645 );
273 646 }
274 647
275 - $this->confirmPaymentSuccessByCharge($transaction, [
276 - 'charge' => Arr::get($response, 'latest_charge', []),
277 - 'intent_id' => $intentId
278 - ]);
648 + // This action is nopriv and carries no nonce, so a reporter may only
649 + // move the transaction to `failed` when it also produced the hash we
650 + // handed the buyer. Confirming a success is safe either way — Stripe's
651 + // own status is the authority there.
652 + $reportedHash = sanitize_text_field((string) App::request()->get('trx_hash'));
653 + $ownsTransaction = $reportedHash !== '' && $reportedHash === (string) $transaction->uuid;
279 654
655 + if (!$this->applyIntentOutcome($transaction, $intentId, $response, $ownsTransaction)) {
656 + // An in-flight charge is not a decline. Telling the buyer to try again
657 + // invites a resubmit for money Stripe is already taking.
658 + if (in_array(Arr::get($response, 'status'), ['processing', 'requires_capture'], true)) {
659 + wp_send_json(
660 + [
661 + 'message' => __('Your payment is still being processed by Stripe. Please do not submit it again — we will confirm your order as soon as it settles.', 'fluent-cart'),
662 + 'transaction_status' => $this->reportedTransactionStatus($transaction),
663 + ],
664 + 400
665 + );
666 + }
667 +
668 + wp_send_json(
669 + [
670 + 'message' => Arr::get(
671 + $response,
672 + 'last_payment_error.message',
673 + __('The payment could not be completed. Please try again.', 'fluent-cart')
674 + ),
675 + 'transaction_status' => $this->reportedTransactionStatus($transaction),
676 + ],
677 + 400
678 + );
679 + }
680 +
280 681 wp_send_json(
281 682 [
282 - 'redirect_url' => $transaction->getReceiptPageUrl(),
683 + 'redirect_url' => $transaction->getSuccessUrl(),
283 684 'order' => [
284 685 'uuid' => $transaction->order->uuid,
285 686 ],
286 687 'message' => __('Payment confirmed successfully. Redirecting...!', 'fluent-cart')
@@ -325,42 +726,30 @@
325 726 'id' => Arr::get($method, 'id'),
326 727 'type' => $type,
327 728 ];
328 729
329 - $fingerprint = null;
330 - switch ($type) {
331 - case 'card':
332 - $pm['last4'] = Arr::get($method, 'card.last4');
333 - $pm['brand'] = Arr::get($method, 'card.brand');
334 - $pm['exp_month'] = Arr::get($method, 'card.exp_month');
335 - $pm['exp_year'] = Arr::get($method, 'card.exp_year');
336 - $pm['fingerprint'] = Arr::get($method, 'card.fingerprint');
337 - $fingerprint = $pm['fingerprint'];
730 + $details = Arr::get($method, $type);
731 + if (!is_array($details)) {
732 + $details = [];
733 + }
734 +
735 + foreach (['last4', 'brand', 'exp_month', 'exp_year', 'fingerprint'] as $field) {
736 + if (Arr::has($details, $field)) {
737 + $pm[$field] = Arr::get($details, $field);
738 + }
739 + }
740 +
741 + // Identifier for account-like methods: link.email, paypal.payer_email,
742 + // cashapp.cashtag — first one present labels the entry in the UI.
743 + foreach (['email', 'payer_email', 'cashtag'] as $field) {
744 + if (Arr::get($details, $field)) {
745 + $pm['email'] = Arr::get($details, $field);
338 746 break;
339 -// case 'sepa_debit':
340 -// $pm['last4'] = Arr::get($method, 'sepa_debit.last4');
341 -// $fingerprint = Arr::get($method, 'sepa_debit.fingerprint');
342 -// break;
343 -// case 'ach_debit':
344 -// $pm['last4'] = Arr::get($method, 'ach_debit.last4');
345 -// $fingerprint = Arr::get($method, 'ach_debit.fingerprint');
346 -// break;
347 -// case 'ach_credit_transfer':
348 -// $pm['account_number'] = Arr::get($method, 'ach_credit_transfer.account_number');
349 -// $fingerprint = Arr::get($method, 'ach_credit_transfer.fingerprint');
350 -// break;
351 -// case 'us_bank_account':
352 -// $pm['account_number'] = Arr::get($method, 'us_bank_account.account_number');
353 -// $fingerprint = Arr::get($method, 'us_bank_account.fingerprint');
354 -// break;
355 -// case 'bacs_debit':
356 -// $pm['account_number'] = Arr::get($method, 'bacs_debit.account_number');
357 -// $fingerprint = Arr::get($method, 'bacs_debit.fingerprint');
358 -// break;
359 - default:
360 - break;
747 + }
361 748 }
362 749
750 + $fingerprint = Arr::get($details, 'fingerprint');
751 +
363 752 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
364 753 continue;
365 754 }
366 755 if ($fingerprint) {
@@ -375,20 +764,21 @@
375 764 }
376 765 }
377 766
378 767 $meta = $fctCustomer->getMeta($metaKey);
768 + if (!is_array($meta)) {
769 + $meta = [];
770 + }
379 771 $meta['stripe'] = $stripeMeta;
380 772
381 - $fctCustomer->updateMeta($metaKey, [
382 - 'stripe' => $stripeMeta
383 - ]);
773 + $fctCustomer->updateMeta($metaKey, $meta);
384 774 }
385 775
386 - public function confirmSetupIntent($setupIntent)
776 + public function confirmSetupIntent($setupIntent, $trxHash = null, $mode = 'current')
387 777 {
388 778 $api = new API();
389 779
390 - $response = $api->getStripeObject('setup_intents/' . $setupIntent);
780 + $response = $api->getStripeObject('setup_intents/' . $setupIntent, [], $mode);
391 781
392 782 if (is_wp_error($response)) {
393 783 return $response;
394 784 }
@@ -401,8 +791,40 @@
401 791 __('Transaction not found for the provided setup intent.', 'fluent-cart')
402 792 );
403 793 }
404 794
795 + if ($trxHash !== null && $transaction->uuid !== $trxHash) {
796 + return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
797 + }
798 +
799 + $setupStatus = Arr::get($response, 'status');
800 +
801 + if ($setupStatus !== 'succeeded') {
802 + // A vaulting failure carries the same idempotency consequence as a
803 + // charge failure: left pending, CheckoutProcessor never bumps
804 + // `payment_attempt`, so the retry reuses the seed and Stripe replays
805 + // its cached response for an intent that can no longer be confirmed.
806 + $failure = $this->intentFailureContext($setupStatus, Arr::get($response, 'last_setup_error', []));
807 +
808 + if (in_array($setupStatus, ['requires_payment_method', 'canceled'], true)) {
809 + $this->markIntentFailed($transaction, $failure);
810 + } else {
811 + $this->logIntentOutcome(
812 + $transaction,
813 + $failure['is_auth_failure']
814 + ? __('Stripe 3D Secure Authentication Not Completed', 'fluent-cart')
815 + : __('Stripe Payment Method Setup Not Completed', 'fluent-cart'),
816 + $failure['detail'],
817 + 'warning'
818 + );
819 + }
820 +
821 + return new \WP_Error(
822 + 'setup_intent_not_succeeded',
823 + __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
824 + );
825 + }
826 +
405 827 $transaction->status = Status::TRANSACTION_PENDING;
406 828
407 829 if ($transaction->total <= 0) {
408 830 $transaction->status = Status::TRANSACTION_SUCCEEDED;
@@ -417,15 +839,15 @@
417 839
418 840 $paymentMethod = Arr::get($response, 'payment_method');
419 841 $customer = Arr::get($response, 'customer');
420 842
421 - $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
843 + $billingInfo = $this->getPaymentMethodDetails($paymentMethod, $mode);
422 844
423 845 // attach the payment method to the customer
424 846 if ($paymentMethod && $customer) {
425 847 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
426 848 'customer' => $customer
427 - ]);
849 + ], $mode);
428 850
429 851 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
430 852 }
431 853
@@ -432,19 +854,81 @@
432 854
433 855 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
434 856
435 857 if ($subscription) {
436 - (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
858 + if ($subscription->isSystem()) {
859 + // Zero-payable free-trial checkout: no vendor subscription to confirm,
860 + // just vault the Stripe customer + reusable payment method.
861 + $stripeCustomerId = Arr::get($response, 'customer', '');
862 + if ($stripeCustomerId && !$subscription->vendor_customer_id) {
863 + $subscription->vendor_customer_id = $stripeCustomerId;
864 + $subscription->save();
865 + }
866 +
867 + $vendorMethodId = Arr::get($response, 'payment_method', '');
868 + if ($vendorMethodId) {
869 + $billingInfo['vendor_method_id'] = $vendorMethodId;
870 + }
871 +
872 + $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
873 + } else {
874 + (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
875 + }
437 876 }
438 877
439 878 (new StatusHelper($order))->syncOrderStatuses($transaction);
440 879
880 + // Notify that a renewal invoice has been deferred — the actual charge will fire
881 + // later via the gateway's subscription_cycle webhook. Gateways that capture a card
882 + // for a deferred renewal charge should fire this so the invoice status can be updated.
883 + if ($order->type === Status::ORDER_TYPE_RENEWAL) {
884 + do_action('fluent_cart/renewal/payment_scheduled', [
885 + 'order' => $order,
886 + 'subscription' => $subscription,
887 + ]);
888 + }
889 +
441 890 }
442 891
443 - public function getPaymentMethodDetails($methodId)
892 + /**
893 + * Vault the token for a system subscription, or demote to manual when the
894 + * initial checkout capture came back without one — mirrors PayPal's
895 + * Processor::maybePersistVaultToken().
896 + */
897 + private function maybePersistSystemVaultToken($subscription, $order, $billingInfo)
444 898 {
445 - $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
899 + $vendorMethodId = Arr::get($billingInfo, 'vendor_method_id', '');
900 + $existing = $subscription->getMeta('active_payment_method', []) ?: [];
901 + // Meta has two shapes in the wild: vendor_method_id (confirmation paths) and
902 + // details.payment_method_id (card-update flow) — accept both, same as chargeRenewal().
903 + $existingMethodId = Arr::get($existing, 'vendor_method_id') ?: Arr::get($existing, 'details.payment_method_id');
446 904
905 + if ($vendorMethodId) {
906 + if ($existingMethodId === $vendorMethodId) {
907 + return; // already persisted (webhook/AJAX race)
908 + }
909 +
910 + $subscription->updateMeta('active_payment_method', $billingInfo);
911 + return;
912 + }
913 +
914 + // No token on the initial capture and none stored yet — never leave a
915 + // system subscription that can never be charged.
916 + if ($order
917 + && $order->type === Status::ORDER_TYPE_SUBSCRIPTION
918 + && !$existingMethodId
919 + ) {
920 + SystemChargeService::demoteToManual(
921 + $subscription,
922 + __('Stripe did not return a saved payment method for automatic charging.', 'fluent-cart')
923 + );
924 + }
925 + }
926 +
927 + public function getPaymentMethodDetails($methodId, $mode = 'current')
928 + {
929 + $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey($mode), 'GET');
930 +
447 931 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
448 932 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
449 933 } else {
450 934 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethodDetails);
@@ -453,8 +937,70 @@
453 937 return $billingInfo;
454 938 }
455 939
456 940
941 + public function syncRemoteTransaction(OrderTransaction $transaction)
942 + {
943 + $mode = $transaction->payment_mode;
944 + if (!$mode) {
945 + $mode = $transaction->order ? $transaction->order->mode : '';
946 + }
947 +
948 + $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
949 + 'expand' => ['latest_charge']
950 + ], $mode);
951 +
952 + if (is_wp_error($intent)) {
953 + return $intent;
954 + }
955 +
956 + $intentStatus = Arr::get($intent, 'status');
957 +
958 + if ($intentStatus === 'succeeded') {
959 + $chargeCurrency = strtoupper((string) Arr::get($intent, 'latest_charge.currency', ''));
960 + if ($chargeCurrency && $transaction->currency && strtoupper($transaction->currency) !== $chargeCurrency) {
961 + fluent_cart_warning_log(
962 + __('Stripe Currency Mismatch On Sync', 'fluent-cart'),
963 + sprintf(
964 + /* translators: %1$s: expected currency, %2$s: received currency */
965 + __('Charge currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
966 + $transaction->currency,
967 + $chargeCurrency
968 + ),
969 + [
970 + 'module_name' => 'order',
971 + 'module_id' => $transaction->order_id,
972 + 'log_type' => 'api'
973 + ]
974 + );
975 +
976 + return new \WP_Error('currency_mismatch', __('The Stripe payment currency does not match this transaction. Please verify the payment at Stripe.', 'fluent-cart'));
977 + }
978 +
979 + $this->confirmPaymentSuccessByCharge($transaction, [
980 + 'charge' => Arr::get($intent, 'latest_charge', []),
981 + 'intent_id' => Arr::get($intent, 'id'),
982 + ]);
983 +
984 + return OrderTransaction::query()->find($transaction->id);
985 + }
986 +
987 + if ($intentStatus === 'processing') {
988 + return new \WP_Error('still_processing', __('The payment is still processing at Stripe. Please try again later.', 'fluent-cart'));
989 + }
990 +
991 + $failureMessage = Arr::get($intent, 'last_payment_error.message');
992 + if (!$failureMessage) {
993 + $failureMessage = sprintf(
994 + /* translators: %1$s: Stripe payment intent status */
995 + __('The payment has not completed at Stripe (status: %1$s).', 'fluent-cart'),
996 + $intentStatus ?: 'unknown'
997 + );
998 + }
999 +
1000 + return new \WP_Error('charge_not_completed', $failureMessage);
1001 + }
1002 +
457 1003 /**
458 1004 * Confirm payment success by charge.
459 1005 * Currently used by:
460 1006 * - fluent_cart/payments/stripe/webhook_charge_succeeded
@@ -480,9 +1026,10 @@
480 1026 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
481 1027 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
482 1028 if ($transaction->subscription_id) {
483 1029 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
484 - if ($subscription) {
1030 + // Only automatic subs have a remote to resync; store-managed (system/manual) have none.
1031 + if ($subscription && $subscription->vendor_subscription_id) {
485 1032 $subscription->reSyncFromRemote();
486 1033 }
487 1034 }
488 1035
@@ -488,8 +1035,14 @@
488 1035
489 1036 return (new StatusHelper($order))->syncOrderStatuses($transaction);
490 1037 }
491 1038
1039 + // Bail before the dispute round-trip below, which would otherwise annotate
1040 + // a row this confirmation is not allowed to touch.
1041 + if (in_array($transaction->status, $this->postPaymentStatuses(), true)) {
1042 + return (new StatusHelper($order))->syncOrderStatuses($transaction);
1043 + }
1044 +
492 1045 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
493 1046 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
494 1047
495 1048 if ($status === Status::TRANSACTION_PENDING) {
@@ -522,9 +1075,9 @@
522 1075 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
523 1076 $disputeId = Arr::get($charge, 'dispute', '');
524 1077 $reason = 'unknown';
525 1078
526 - $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
1079 + $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId, [], StripeHelper::modeFromLivemode(Arr::isTrue($charge, 'livemode')));
527 1080
528 1081 if (!is_wp_error($retreiveDispute)) {
529 1082 $reason = Arr::get($retreiveDispute, 'reason');
530 1083 }
@@ -550,11 +1103,52 @@
550 1103 ]);
551 1104 }
552 1105 }
553 1106
1107 + // Stripe's charge `created` is when the money actually moved. When this
1108 + // confirmation is the first path to mark the transaction succeeded, it
1109 + // beats the model hook's fallback now() stamp — which for a delayed
1110 + // webhook would be the (later) processing time, not the charge time.
1111 + $chargeCreatedAt = (int)Arr::get($charge, 'created', 0);
1112 + if ($chargeCreatedAt && empty($transaction->meta['settled_at'])) {
1113 + $transaction->meta = array_merge($transaction->meta, [
1114 + 'settled_at' => DateTime::anyTimeToGmt($chargeCreatedAt)->format('Y-m-d H:i:s')
1115 + ]);
1116 + }
1117 +
554 1118 $transaction->fill($transactionUpdateData);
555 - $transaction->save();
1119 + $transaction->updated_at = DateTime::gmtNow();
556 1120
1121 + // The re-read at the top of this method is a check, not a claim, and the
1122 + // disputed branch above spends a remote round-trip inside the window it
1123 + // leaves open. Write through a guarded UPDATE so a refund landing there
1124 + // wins. `succeeded` and `authorized` stay writable: the first is
1125 + // idempotent here, the second is exactly what capture moves forward.
1126 + $dirty = $transaction->getDirty();
1127 +
1128 + if ($dirty) {
1129 + OrderTransaction::query()
1130 + ->where('id', $transaction->id)
1131 + ->whereNotIn('status', $this->postPaymentStatuses())
1132 + ->update($dirty);
1133 + }
1134 +
1135 + // Decide on the row, not on the affected-row count — an identical replay
1136 + // inside the same second changes nothing and still reports zero.
1137 + $confirmed = OrderTransaction::query()->find($transaction->id);
1138 +
1139 + if (!$confirmed) {
1140 + return $order;
1141 + }
1142 +
1143 + // Settled behind our back: sync the order and skip the confirmation side
1144 + // effects below — logs, subscription activation, vault persistence.
1145 + if (in_array($confirmed->status, $this->postPaymentStatuses(), true)) {
1146 + return (new StatusHelper($order))->syncOrderStatuses($confirmed);
1147 + }
1148 +
1149 + $transaction = $confirmed;
1150 +
557 1151 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
558 1152 'module_name' => 'order',
559 1153 'module_id' => $order->id,
560 1154 ]);
@@ -594,11 +1188,8 @@
594 1188 if (!$subscription) {
595 1189 return $order; // No subscription found for this renewal order. Something is wrong.
596 1190 }
597 1191
598 - $api = new API();
599 - $response = $api->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
600 -
601 1192 $subscriptionArgs = [
602 1193 'status' => Status::SUBSCRIPTION_ACTIVE,
603 1194 'canceled_at' => null,
604 1195 'current_payment_method' => 'stripe'
@@ -603,12 +1194,17 @@
603 1194 'canceled_at' => null,
604 1195 'current_payment_method' => 'stripe'
605 1196 ];
606 1197
607 - if (!is_wp_error($response)) {
608 - $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
609 - if ($nextBillingDate) {
610 - $subscriptionArgs['next_billing_date'] = gmdate('Y-m-d H:i:s', (int)$nextBillingDate);
1198 + // Only automatic subs expose a Stripe subscription to read the period end from;
1199 + // store-managed (system/manual) advance next_billing_date via handleRenewalPaid.
1200 + if ($subscription->vendor_subscription_id) {
1201 + $response = (new API())->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
1202 + if (!is_wp_error($response)) {
1203 + $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
1204 + if ($nextBillingDate) {
1205 + $subscriptionArgs['next_billing_date'] = gmdate('Y-m-d H:i:s', (int)$nextBillingDate);
1206 + }
611 1207 }
612 1208 }
613 1209
614 1210 SubscriptionService::recordManualRenewal($subscription, $transaction, [
@@ -622,8 +1218,21 @@
622 1218 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
623 1219 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
624 1220 }
625 1221
1222 + // System (auto-charged, store-billed) subscription: persist the token from
1223 + // the first charge — the only write path for it, since
1224 + // confirmSubscriptionAfterChargeSucceeded() early-returns without a vendor subscription.
1225 + if ($subscription && $subscription->isSystem()) {
1226 + $stripeCustomerId = Arr::get($charge, 'customer', '');
1227 + if ($stripeCustomerId && !$subscription->vendor_customer_id) {
1228 + $subscription->vendor_customer_id = $stripeCustomerId;
1229 + $subscription->save();
1230 + }
1231 +
1232 + $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
1233 + }
1234 +
626 1235 (new StatusHelper($order))->syncOrderStatuses($transaction);
627 1236 }
628 1237
629 1238 return $order;
@@ -629,5 +1238,4 @@
629 1238 return $order;
630 1239 }
631 1240
632 1241 }
633 -