PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 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 All 50 releases
← All changes | app/Modules/PaymentMethods/StripeGateway/Confirmations.php +656 -70 1.4.2 → 1.7.0 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)) {
@@ -246,9 +605,12 @@
246 605 $result = $this->confirmSetupIntent($intentId, $trxHash);
247 606 if (is_wp_error($result)) {
248 607 wp_send_json(
249 608 [
250 - 'message' => $result->get_error_message(),
609 + 'message' => $result->get_error_message(),
610 + 'transaction_status' => $this->reportedTransactionStatus(
611 + OrderTransaction::query()->where('uuid', $trxHash)->first()
612 + ),
251 613 ], 400
252 614 );
253 615 }
254 616 wp_send_json(
@@ -282,16 +644,44 @@
282 644 404
283 645 );
284 646 }
285 647
286 - $this->confirmPaymentSuccessByCharge($transaction, [
287 - 'charge' => Arr::get($response, 'latest_charge', []),
288 - 'intent_id' => $intentId
289 - ]);
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;
290 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 +
291 681 wp_send_json(
292 682 [
293 - 'redirect_url' => $transaction->getReceiptPageUrl(),
683 + 'redirect_url' => $transaction->getSuccessUrl(),
294 684 'order' => [
295 685 'uuid' => $transaction->order->uuid,
296 686 ],
297 687 'message' => __('Payment confirmed successfully. Redirecting...!', 'fluent-cart')
@@ -336,42 +726,30 @@
336 726 'id' => Arr::get($method, 'id'),
337 727 'type' => $type,
338 728 ];
339 729
340 - $fingerprint = null;
341 - switch ($type) {
342 - case 'card':
343 - $pm['last4'] = Arr::get($method, 'card.last4');
344 - $pm['brand'] = Arr::get($method, 'card.brand');
345 - $pm['exp_month'] = Arr::get($method, 'card.exp_month');
346 - $pm['exp_year'] = Arr::get($method, 'card.exp_year');
347 - $pm['fingerprint'] = Arr::get($method, 'card.fingerprint');
348 - $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);
349 746 break;
350 -// case 'sepa_debit':
351 -// $pm['last4'] = Arr::get($method, 'sepa_debit.last4');
352 -// $fingerprint = Arr::get($method, 'sepa_debit.fingerprint');
353 -// break;
354 -// case 'ach_debit':
355 -// $pm['last4'] = Arr::get($method, 'ach_debit.last4');
356 -// $fingerprint = Arr::get($method, 'ach_debit.fingerprint');
357 -// break;
358 -// case 'ach_credit_transfer':
359 -// $pm['account_number'] = Arr::get($method, 'ach_credit_transfer.account_number');
360 -// $fingerprint = Arr::get($method, 'ach_credit_transfer.fingerprint');
361 -// break;
362 -// case 'us_bank_account':
363 -// $pm['account_number'] = Arr::get($method, 'us_bank_account.account_number');
364 -// $fingerprint = Arr::get($method, 'us_bank_account.fingerprint');
365 -// break;
366 -// case 'bacs_debit':
367 -// $pm['account_number'] = Arr::get($method, 'bacs_debit.account_number');
368 -// $fingerprint = Arr::get($method, 'bacs_debit.fingerprint');
369 -// break;
370 - default:
371 - break;
747 + }
372 748 }
373 749
750 + $fingerprint = Arr::get($details, 'fingerprint');
751 +
374 752 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
375 753 continue;
376 754 }
377 755 if ($fingerprint) {
@@ -386,20 +764,21 @@
386 764 }
387 765 }
388 766
389 767 $meta = $fctCustomer->getMeta($metaKey);
768 + if (!is_array($meta)) {
769 + $meta = [];
770 + }
390 771 $meta['stripe'] = $stripeMeta;
391 772
392 - $fctCustomer->updateMeta($metaKey, [
393 - 'stripe' => $stripeMeta
394 - ]);
773 + $fctCustomer->updateMeta($metaKey, $meta);
395 774 }
396 775
397 - public function confirmSetupIntent($setupIntent, $trxHash = null)
776 + public function confirmSetupIntent($setupIntent, $trxHash = null, $mode = 'current')
398 777 {
399 778 $api = new API();
400 779
401 - $response = $api->getStripeObject('setup_intents/' . $setupIntent);
780 + $response = $api->getStripeObject('setup_intents/' . $setupIntent, [], $mode);
402 781
403 782 if (is_wp_error($response)) {
404 783 return $response;
405 784 }
@@ -416,9 +795,30 @@
416 795 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
417 796 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
418 797 }
419 798
420 - if (Arr::get($response, 'status') !== 'succeeded') {
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 +
421 821 return new \WP_Error(
422 822 'setup_intent_not_succeeded',
423 823 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
424 824 );
@@ -439,15 +839,15 @@
439 839
440 840 $paymentMethod = Arr::get($response, 'payment_method');
441 841 $customer = Arr::get($response, 'customer');
442 842
443 - $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
843 + $billingInfo = $this->getPaymentMethodDetails($paymentMethod, $mode);
444 844
445 845 // attach the payment method to the customer
446 846 if ($paymentMethod && $customer) {
447 847 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
448 848 'customer' => $customer
449 - ]);
849 + ], $mode);
450 850
451 851 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
452 852 }
453 853
@@ -454,19 +854,81 @@
454 854
455 855 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
456 856
457 857 if ($subscription) {
458 - (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 + }
459 876 }
460 877
461 878 (new StatusHelper($order))->syncOrderStatuses($transaction);
462 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 +
463 890 }
464 891
465 - 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)
466 898 {
467 - $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');
468 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 +
469 931 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
470 932 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
471 933 } else {
472 934 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethodDetails);
@@ -475,8 +937,70 @@
475 937 return $billingInfo;
476 938 }
477 939
478 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 +
479 1003 /**
480 1004 * Confirm payment success by charge.
481 1005 * Currently used by:
482 1006 * - fluent_cart/payments/stripe/webhook_charge_succeeded
@@ -502,9 +1026,10 @@
502 1026 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
503 1027 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
504 1028 if ($transaction->subscription_id) {
505 1029 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
506 - if ($subscription) {
1030 + // Only automatic subs have a remote to resync; store-managed (system/manual) have none.
1031 + if ($subscription && $subscription->vendor_subscription_id) {
507 1032 $subscription->reSyncFromRemote();
508 1033 }
509 1034 }
510 1035
@@ -510,8 +1035,14 @@
510 1035
511 1036 return (new StatusHelper($order))->syncOrderStatuses($transaction);
512 1037 }
513 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 +
514 1045 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
515 1046 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
516 1047
517 1048 if ($status === Status::TRANSACTION_PENDING) {
@@ -544,9 +1075,9 @@
544 1075 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
545 1076 $disputeId = Arr::get($charge, 'dispute', '');
546 1077 $reason = 'unknown';
547 1078
548 - $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
1079 + $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId, [], StripeHelper::modeFromLivemode(Arr::isTrue($charge, 'livemode')));
549 1080
550 1081 if (!is_wp_error($retreiveDispute)) {
551 1082 $reason = Arr::get($retreiveDispute, 'reason');
552 1083 }
@@ -572,11 +1103,52 @@
572 1103 ]);
573 1104 }
574 1105 }
575 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 +
576 1118 $transaction->fill($transactionUpdateData);
577 - $transaction->save();
1119 + $transaction->updated_at = DateTime::gmtNow();
578 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 +
579 1151 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
580 1152 'module_name' => 'order',
581 1153 'module_id' => $order->id,
582 1154 ]);
@@ -616,11 +1188,8 @@
616 1188 if (!$subscription) {
617 1189 return $order; // No subscription found for this renewal order. Something is wrong.
618 1190 }
619 1191
620 - $api = new API();
621 - $response = $api->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
622 -
623 1192 $subscriptionArgs = [
624 1193 'status' => Status::SUBSCRIPTION_ACTIVE,
625 1194 'canceled_at' => null,
626 1195 'current_payment_method' => 'stripe'
@@ -625,12 +1194,17 @@
625 1194 'canceled_at' => null,
626 1195 'current_payment_method' => 'stripe'
627 1196 ];
628 1197
629 - if (!is_wp_error($response)) {
630 - $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
631 - if ($nextBillingDate) {
632 - $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 + }
633 1207 }
634 1208 }
635 1209
636 1210 SubscriptionService::recordManualRenewal($subscription, $transaction, [
@@ -644,8 +1218,21 @@
644 1218 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
645 1219 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
646 1220 }
647 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 +
648 1235 (new StatusHelper($order))->syncOrderStatuses($transaction);
649 1236 }
650 1237
651 1238 return $order;
@@ -651,5 +1238,4 @@
651 1238 return $order;
652 1239 }
653 1240
654 1241 }
655 -