PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/PaymentMethods/StripeGateway/Processor.php +540 -43 1.6.2 → 1.6.5 View file →
@@ -12,8 +12,14 @@
12 12
13 13 class Processor
14 14 {
15 15 /**
16 + * Stripe Checkout caps a session at 100 line items. Kept below it with room
17 + * for the tax and reconciliation lines this builder may append.
18 + */
19 + const MAX_HOSTED_LINE_ITEMS = 90;
20 +
21 + /**
16 22 * The return URL handed to Stripe for an onsite confirm.
17 23 *
18 24 * Onsite normally never navigates (`redirect: 'if_required'`), but an
19 25 * issuer that forces a full 3DS redirect sends the buyer here. Deliberately
@@ -921,25 +927,19 @@
921 927 if (is_wp_error($stripeCustomer)) {
922 928 return $stripeCustomer;
923 929 }
924 930
925 - // Use a single line item with the total amount to avoid complexity
926 - // This is simpler and prevents any calculation mismatches
927 - $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
928 - $lineItems = [
929 - [
930 - 'price_data' => [
931 - 'currency' => strtolower($transactionCurrency),
932 - 'product_data' => [
933 - 'name' => $storeName . ' - Order #' . $order->uuid,
934 - 'description' => sprintf(__('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart')),
935 - ],
936 - 'unit_amount' => $chargeAmount,
937 - ],
938 - 'quantity' => 1,
939 - ]
940 - ];
931 + // Per-item breakdown when it reconciles exactly to the charge amount,
932 + // otherwise the historical single aggregate line. buildHostedLineItems()
933 + // returns null for anything it cannot prove sums to $chargeAmount — the
934 + // fallback is always a correct charge, just a less itemised one.
935 + $lineItems = $this->buildHostedLineItems($order, $transactionCurrency, $chargeAmount);
936 + $usedBreakdown = $lineItems !== null;
941 937
938 + if (!$usedBreakdown) {
939 + $lineItems = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
940 + }
941 +
942 942 $sessionData = [
943 943 'customer' => $stripeCustomer['id'],
944 944 'client_reference_id' => $order->uuid,
945 945 'line_items' => $lineItems,
@@ -961,8 +961,13 @@
961 961 'setup_future_usage' => $paymentArgs['setup_future_usage'],
962 962 ];
963 963 }
964 964
965 + $submitType = (new StripeSettingsBase())->getSubmitType();
966 + if ($submitType && in_array($submitType, ['auto', 'book', 'donate', 'pay'], true)) {
967 + $sessionData['submit_type'] = $submitType;
968 + }
969 +
965 970 $itemCount = 1;
966 971 foreach($order->order_items as $item) {
967 972 $sessionData['metadata']['item ' . $itemCount] = 'Name: ' . $item->title . ', ' . 'Qty: ' . $item->quantity . ', Price: ' . Helper::toDecimal($item->line_total, false, null, true, true, false);
968 973 if (count($sessionData['metadata']) > 49) {
@@ -971,30 +976,61 @@
971 976
972 977 $itemCount++;
973 978 }
974 979
980 + // Kept unfiltered so the aggregate retry below can re-derive the body from
981 + // the same base rather than editing a filtered one underneath its author.
982 + $baseSessionData = $sessionData;
983 +
975 984 $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $sessionData, [
976 985 'order' => $order,
977 986 'transaction' => $transaction
978 987 ]);
979 988
980 - // Same duplicate-charge defense as every other Stripe create path: a pure
981 - // duplicate replays the key and gets the original session back; an edited-cart
982 - // resubmit gets a fresh key instead of a same-key/changed-parameters 400.
983 - $idempotencyFingerprint = [
984 - 'customer' => Arr::get($sessionData, 'customer'),
985 - 'line_items' => Arr::get($sessionData, 'line_items'),
986 - 'mode' => Arr::get($sessionData, 'mode'),
987 - ];
988 989 $idempotencySeed = $paymentInstance->getIdempotencySeed();
989 - $idempotencyKey = $idempotencySeed
990 - ? 'fct_stripe_cs_' . md5($idempotencySeed . '|' . wp_json_encode($idempotencyFingerprint))
991 - : null;
990 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
992 991
993 992 $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
994 993 'Idempotency-Key' => $idempotencyKey
995 994 ]);
996 995
996 + // A breakdown adds validation surface the single aggregate line does not
997 + // have (line-item cap, product naming, per-item amounts). If Stripe rejects
998 + // the itemised body, fall back to the aggregate rather than failing the
999 + // buyer's checkout. Only a structural rejection qualifies: a transport
1000 + // failure may mean the session was in fact created, and retrying that under
1001 + // a different key would abandon the idempotency guarantee for no reason.
1002 + if ($usedBreakdown && $this->isStripeValidationError($session)) {
1003 + fluent_cart_warning_log(
1004 + 'Stripe checkout line-item breakdown rejected',
1005 + 'Stripe rejected the itemised checkout session; retrying with a single aggregate line item. Reason: ' . $session->get_error_message(),
1006 + [
1007 + 'module_name' => 'order',
1008 + 'module_id' => $order->id,
1009 + 'log_type' => 'api'
1010 + ]
1011 + );
1012 +
1013 + // Re-filter the aggregate body instead of swapping line_items inside the
1014 + // filtered one: a subscriber that derives anything from line_items (per
1015 + // line tax_rates, automatic_tax, its own totals) decided that against the
1016 + // itemised body, and editing underneath it leaves the two disagreeing.
1017 + // Rebuilt from the unfiltered base so a subscriber that appends rather
1018 + // than replaces does not apply twice.
1019 + $baseSessionData['line_items'] = $this->aggregateHostedLineItems($order, $transactionCurrency, $chargeAmount);
1020 +
1021 + $sessionData = apply_filters('fluent_cart/payments/stripe_checkout_session_args', $baseSessionData, [
1022 + 'order' => $order,
1023 + 'transaction' => $transaction
1024 + ]);
1025 +
1026 + $idempotencyKey = $this->hostedSessionIdempotencyKey($idempotencySeed, $sessionData);
1027 +
1028 + $session = (new API())->createStripeObject('checkout/sessions', $sessionData, 'current', [
1029 + 'Idempotency-Key' => $idempotencyKey
1030 + ]);
1031 + }
1032 +
997 1033 if (is_wp_error($session)) {
998 1034 return $session;
999 1035 }
1000 1036
@@ -1017,8 +1053,455 @@
1017 1053 ];
1018 1054 }
1019 1055
1020 1056
1057 + /**
1058 + * Itemised line_items for a hosted (mode: payment) Checkout Session.
1059 + *
1060 + * Unlike PayPal's purchase_unit — where we declare amount.value and the
1061 + * breakdown merely has to agree with it — Stripe DERIVES the session total
1062 + * from line_items. There is no total to assert and no subtractive field
1063 + * (order-level discounts need a Coupon object; negative unit_amount is
1064 + * rejected). So the sum of what we send IS what the buyer is charged, and a
1065 + * breakdown that is a cent off does not error, it mischarges.
1066 + *
1067 + * Everything is therefore reconciled against $chargeAmount before returning,
1068 + * in wire units, and null is returned for any order this cannot prove exact.
1069 + * The caller then sends a single aggregate line — always the correct amount.
1070 + *
1071 + * Discounts need no line of their own: order_items.line_total is already
1072 + * subtotal minus discount_total (CheckoutProcessor::116), so item-level
1073 + * discounts are baked into the per-unit price.
1074 + *
1075 + * @param \FluentCart\App\Models\Order $order
1076 + * @param string $currency
1077 + * @param int $chargeAmount Charge total in wire units (already divided for zero-decimal).
1078 + * @return array|null Null when no exact breakdown is possible.
1079 + */
1080 + /**
1081 + * Duplicate-charge defense for a hosted payment session: a pure duplicate
1082 + * replays the key and gets the original session back, while an edited-cart
1083 + * resubmit gets a fresh key instead of a same-key/changed-parameters 400.
1084 + *
1085 + * Derived from the FILTERED body, so a subscriber that changes what is
1086 + * actually charged changes the key with it. Metadata is excluded — a
1087 + * volatile metadata filter must not roll the key on a genuine duplicate.
1088 + *
1089 + * @param string|null $seed
1090 + * @param array $sessionData
1091 + * @return string|null
1092 + */
1093 + private function hostedSessionIdempotencyKey($seed, $sessionData)
1094 + {
1095 + if (!$seed) {
1096 + return null;
1097 + }
1098 +
1099 + $fingerprint = [
1100 + 'customer' => Arr::get($sessionData, 'customer'),
1101 + 'line_items' => Arr::get($sessionData, 'line_items'),
1102 + 'mode' => Arr::get($sessionData, 'mode'),
1103 + ];
1104 +
1105 + return 'fct_stripe_cs_' . md5($seed . '|' . wp_json_encode($fingerprint));
1106 + }
1107 +
1108 + private function buildHostedLineItems($order, $currency, $chargeAmount)
1109 + {
1110 + $isZeroDecimal = $currency && CurrenciesHelper::isZeroDecimal($currency);
1111 + $stripeCurrency = strtolower($currency);
1112 +
1113 + $lineItems = [];
1114 + $sum = 0;
1115 +
1116 + // The relation is materialised on this request either way (the metadata
1117 + // loop below walks it), so reuse it instead of issuing a second query.
1118 + // Deterministic order: the idempotency fingerprint hashes line_items, so a
1119 + // varying sequence would roll the key on a genuine duplicate submission.
1120 + $orderItems = $order->order_items->sortBy('id')->values();
1121 +
1122 + // Decline before building anything the cap would discard.
1123 + if ($orderItems->count() > self::MAX_HOSTED_LINE_ITEMS) {
1124 + return null;
1125 + }
1126 +
1127 + foreach ($orderItems as $item) {
1128 + $quantity = (int) $item->quantity;
1129 + if ($quantity < 1) {
1130 + $quantity = 1;
1131 + }
1132 +
1133 + $lineTotal = $this->toStripeWireAmount($item->line_total, $isZeroDecimal);
1134 +
1135 + // A zero line contributes nothing to the total and Stripe has no use
1136 + // for a zero-priced row here; skipping keeps us under the item cap.
1137 + if ($lineTotal <= 0) {
1138 + continue;
1139 + }
1140 +
1141 + $unitAmount = intdiv($lineTotal, $quantity);
1142 + if ($unitAmount <= 0) {
1143 + continue;
1144 + }
1145 +
1146 + $name = $this->hostedLineItemName($item);
1147 + if ($name === '') {
1148 + return null; // Stripe requires a non-empty product name.
1149 + }
1150 +
1151 + if (count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1152 + return null;
1153 + }
1154 +
1155 + $lineItems[] = [
1156 + 'price_data' => [
1157 + 'currency' => $stripeCurrency,
1158 + 'product_data' => [
1159 + 'name' => $name,
1160 + ],
1161 + 'unit_amount' => $unitAmount,
1162 + ],
1163 + 'quantity' => $quantity,
1164 + ];
1165 +
1166 + // intdiv floors, so any per-unit remainder is left for the
1167 + // reconciliation line below rather than silently inflating the charge.
1168 + $sum += $unitAmount * $quantity;
1169 + }
1170 +
1171 + if (!$lineItems) {
1172 + return null;
1173 + }
1174 +
1175 + $shipping = $this->toStripeWireAmount($order->shipping_total, $isZeroDecimal);
1176 + if ($shipping > 0) {
1177 + $lineItems[] = $this->hostedFlatLineItem(__('Shipping', 'fluent-cart'), $stripeCurrency, $shipping);
1178 + $sum += $shipping;
1179 + }
1180 +
1181 + // Only tax the buyer pays ON TOP of item prices belongs here — inclusive
1182 + // tax is already inside line_total and adding it would double-charge.
1183 + $tax = $this->toStripeWireAmount($this->additiveTaxTotal($order), $isZeroDecimal);
1184 + if ($tax > 0) {
1185 + $lineItems[] = $this->hostedFlatLineItem(__('Tax', 'fluent-cart'), $stripeCurrency, $tax);
1186 + $sum += $tax;
1187 + }
1188 +
1189 + if ($sum > $chargeAmount) {
1190 + return null; // Cannot subtract without a Coupon object; aggregate instead.
1191 + }
1192 +
1193 + if ($sum < $chargeAmount) {
1194 + $lineItems[] = $this->hostedFlatLineItem(
1195 + __('Adjustment', 'fluent-cart'),
1196 + $stripeCurrency,
1197 + $chargeAmount - $sum
1198 + );
1199 + $sum = $chargeAmount;
1200 + }
1201 +
1202 + if ($sum !== $chargeAmount || count($lineItems) > self::MAX_HOSTED_LINE_ITEMS) {
1203 + return null;
1204 + }
1205 +
1206 + return $lineItems;
1207 + }
1208 +
1209 + /**
1210 + * The historical single-line body: one item worth the whole charge. Always a
1211 + * correct amount, and the fallback for every path the breakdown declines.
1212 + *
1213 + * @param \FluentCart\App\Models\Order $order
1214 + * @param string $currency
1215 + * @param int $chargeAmount
1216 + * @return array
1217 + */
1218 + private function aggregateHostedLineItems($order, $currency, $chargeAmount)
1219 + {
1220 + $storeName = (new \FluentCart\Api\StoreSettings())->get('store_name');
1221 +
1222 + return [
1223 + [
1224 + 'price_data' => [
1225 + 'currency' => strtolower($currency),
1226 + 'product_data' => [
1227 + 'name' => $storeName . ' - Order #' . $order->uuid,
1228 + 'description' => __('Order total including all items, shipping (If any), and taxes (If any)', 'fluent-cart'),
1229 + ],
1230 + 'unit_amount' => $chargeAmount,
1231 + ],
1232 + 'quantity' => 1,
1233 + ]
1234 + ];
1235 + }
1236 +
1237 + /**
1238 + * @param string $name
1239 + * @param string $stripeCurrency
1240 + * @param int $amount
1241 + * @return array
1242 + */
1243 + private function hostedFlatLineItem($name, $stripeCurrency, $amount)
1244 + {
1245 + return [
1246 + 'price_data' => [
1247 + 'currency' => $stripeCurrency,
1248 + 'product_data' => [
1249 + 'name' => $name,
1250 + ],
1251 + 'unit_amount' => $amount,
1252 + ],
1253 + 'quantity' => 1,
1254 + ];
1255 + }
1256 +
1257 + /**
1258 + * @param \FluentCart\App\Models\OrderItem $item
1259 + * @return string
1260 + */
1261 + private function hostedLineItemName($item)
1262 + {
1263 + $name = trim($item->post_title . ' ' . $item->title);
1264 +
1265 + if ($name === '') {
1266 + return '';
1267 + }
1268 +
1269 + if (function_exists('mb_substr') && mb_strlen($name) > 250) {
1270 + return mb_substr($name, 0, 247) . '...';
1271 + }
1272 +
1273 + if (strlen($name) > 250) {
1274 + return substr($name, 0, 247) . '...';
1275 + }
1276 +
1277 + return $name;
1278 + }
1279 +
1280 + /**
1281 + * Storage cents to the units Stripe is charged in. Every part of the
1282 + * breakdown is converted individually and the caller reconciles the SUM
1283 + * against the converted charge total — converting after summing would
1284 + * disagree with Stripe, which only ever sees the per-item figures.
1285 + *
1286 + * @param mixed $cents
1287 + * @param bool $isZeroDecimal
1288 + * @return int
1289 + */
1290 + private function toStripeWireAmount($cents, $isZeroDecimal)
1291 + {
1292 + $cents = Helper::roundCent($cents);
1293 +
1294 + return $isZeroDecimal ? intdiv($cents, 100) : $cents;
1295 + }
1296 +
1297 + /**
1298 + * Tax the buyer pays on top of item prices, in storage cents.
1299 + *
1300 + * Mirrors the PayPal purchase-unit breakdown (PayPalGateway/Processor.php):
1301 + * behaviour 1 is fully exclusive, 3 is mixed (only the exclusive portion is
1302 + * additive, with shipping and fee tax additive only when the store itself is
1303 + * exclusive), anything else is inclusive and contributes nothing.
1304 + *
1305 + * @param \FluentCart\App\Models\Order $order
1306 + * @return int
1307 + */
1308 + private function additiveTaxTotal($order)
1309 + {
1310 + $taxBehavior = (int) $order->tax_behavior;
1311 + $exclusiveTaxTotal = (int) $order->getMeta('exclusive_tax_total');
1312 + $storeTaxBehavior = (int) $order->getMeta('store_tax_behavior');
1313 + $feeTax = (int) $order->getMeta('fee_tax');
1314 +
1315 + // Fallback: if meta missing (old order), use tax_behavior as store_tax_behavior
1316 + if (empty($storeTaxBehavior) && $taxBehavior > 0) {
1317 + $storeTaxBehavior = $taxBehavior;
1318 + }
1319 +
1320 + if ($taxBehavior === 1) {
1321 + return Helper::roundCent($order->tax_total) + Helper::roundCent($order->shipping_tax);
1322 + }
1323 +
1324 + if ($taxBehavior === 3) {
1325 + $taxTotal = $exclusiveTaxTotal;
1326 +
1327 + if ($storeTaxBehavior === 1) {
1328 + $taxTotal += Helper::roundCent($order->shipping_tax);
1329 + $taxTotal += $feeTax;
1330 + }
1331 +
1332 + return $taxTotal;
1333 + }
1334 +
1335 + return 0;
1336 + }
1337 +
1338 + /**
1339 + * True only for a structural rejection of the request body (Stripe
1340 + * `invalid_request_error`). A transport failure is deliberately excluded: the
1341 + * session may have been created, and retrying under a different idempotency
1342 + * key would give up the duplicate protection that key exists for.
1343 + *
1344 + * @param mixed $response
1345 + * @return bool
1346 + */
1347 + private function isStripeValidationError($response)
1348 + {
1349 + if (!is_wp_error($response) || $response->get_error_code() !== 'api_error') {
1350 + return false;
1351 + }
1352 +
1353 + $body = $response->get_error_data();
1354 +
1355 + return is_array($body) && Arr::get($body, 'error.type') === 'invalid_request_error';
1356 + }
1357 +
1358 +
1359 + /**
1360 + * The one-time part of a hosted subscription session, itemised.
1361 + *
1362 + * `$initialAmount` is a bundle of up to four unrelated things — a merchant
1363 + * setup fee, one-time cart items, order fees, and a synthetic first-cycle
1364 + * delta the plan price cannot carry — so a single line can only ever be
1365 + * labelled correctly for one of them. Everything the order itemises gets its
1366 + * own line; whatever is left over (tax, the delta) becomes one remainder line
1367 + * named for the case that produced it.
1368 + *
1369 + * Returns null when the breakdown cannot be reconciled to `$initialAmount`,
1370 + * which sends the caller to the aggregate single line.
1371 + *
1372 + * @param PaymentInstance $paymentInstance
1373 + * @param string $stripeCurrency
1374 + * @param bool $isZeroDecimal
1375 + * @param int $initialAmount Wire amount, already converted.
1376 + * @param int $existingCount Lines already in the session body.
1377 + * @return array|null
1378 + */
1379 + private function buildHostedSubscriptionInitialLineItems(PaymentInstance $paymentInstance, $stripeCurrency, $isZeroDecimal, $initialAmount, $existingCount)
1380 + {
1381 + $order = $paymentInstance->order;
1382 +
1383 + $lineItems = [];
1384 + $sum = 0;
1385 +
1386 + // Already materialised by getExtraAddonAmount() before this runs, so reuse
1387 + // the relation rather than querying again. Deterministic order: the
1388 + // idempotency fingerprint hashes line_items.
1389 + $orderItems = $order->order_items->sortBy('id')->values();
1390 +
1391 + // Decline before building anything the cap would discard.
1392 + if ($existingCount + $orderItems->count() > self::MAX_HOSTED_LINE_ITEMS) {
1393 + return null;
1394 + }
1395 +
1396 + foreach ($orderItems as $item) {
1397 + if ($item->payment_type === 'subscription') {
1398 + continue; // Carried by the recurring price.
1399 + }
1400 +
1401 + $lineTotal = $this->toStripeWireAmount($item->line_total, $isZeroDecimal);
1402 + if ($lineTotal <= 0) {
1403 + continue;
1404 + }
1405 +
1406 + $quantity = (int)$item->quantity;
1407 + if ($quantity < 1) {
1408 + $quantity = 1;
1409 + }
1410 +
1411 + $unitAmount = intdiv($lineTotal, $quantity);
1412 + if ($unitAmount <= 0) {
1413 + continue;
1414 + }
1415 +
1416 + // A signup-fee row already carries the merchant's own label in
1417 + // `title` (other_info.signup_fee_name), a fee row the fee label.
1418 + $name = $this->hostedLineItemName($item);
1419 +
1420 + if ($name === '') {
1421 + return null;
1422 + }
1423 +
1424 + if ($existingCount + count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1425 + return null;
1426 + }
1427 +
1428 + $lineItems[] = [
1429 + 'price_data' => [
1430 + 'currency' => $stripeCurrency,
1431 + 'product_data' => [
1432 + 'name' => $name,
1433 + ],
1434 + 'unit_amount' => $unitAmount,
1435 + ],
1436 + 'quantity' => $quantity,
1437 + ];
1438 +
1439 + $sum += $unitAmount * $quantity;
1440 + }
1441 +
1442 + if ($sum > $initialAmount) {
1443 + return null; // Cannot subtract without a Coupon object.
1444 + }
1445 +
1446 + if ($sum < $initialAmount) {
1447 + $remainder = $initialAmount - $sum;
1448 +
1449 + // No itemised part at all means the whole amount IS the first-cycle
1450 + // delta, and it gets the case name rather than a tax label.
1451 + $name = $lineItems
1452 + ? __('Taxes & adjustments', 'fluent-cart')
1453 + : $this->hostedSubscriptionInitialName($order, $paymentInstance->subscription);
1454 +
1455 + if ($existingCount + count($lineItems) >= self::MAX_HOSTED_LINE_ITEMS) {
1456 + return null;
1457 + }
1458 +
1459 + $lineItems[] = $this->hostedFlatLineItem($name, $stripeCurrency, $remainder);
1460 + $sum += $remainder;
1461 + }
1462 +
1463 + if (!$lineItems || $sum !== $initialAmount) {
1464 + return null;
1465 + }
1466 +
1467 + return $lineItems;
1468 + }
1469 +
1470 + /**
1471 + * Label for a one-time amount that the order does not itemise.
1472 + *
1473 + * A configured setup fee owns the label when one exists. Otherwise the amount
1474 + * is a first-cycle delta computed in CheckoutProcessor::convertToSubscriptionFormat():
1475 + * with a simulated trial the recurring line charges nothing now, so this line
1476 + * is the whole first payment; without one it is only the excess over recurring.
1477 + *
1478 + * @param \FluentCart\App\Models\Order $order
1479 + * @param \FluentCart\App\Models\Subscription|null $subscriptionModel
1480 + * @return string
1481 + */
1482 + private function hostedSubscriptionInitialName($order, $subscriptionModel)
1483 + {
1484 + $signupFeeItem = $order->order_items->first(function ($item) {
1485 + return $item->payment_type === 'signup_fee';
1486 + });
1487 +
1488 + if ($signupFeeItem) {
1489 + $name = $this->hostedLineItemName($signupFeeItem);
1490 +
1491 + if ($name !== '') {
1492 + return $name;
1493 + }
1494 + }
1495 +
1496 + $simulatedTrial = $subscriptionModel
1497 + && Arr::get($subscriptionModel->config, 'is_trial_days_simulated', 'no') === 'yes';
1498 +
1499 + return $simulatedTrial
1500 + ? __('First payment', 'fluent-cart')
1501 + : __('First payment adjustment', 'fluent-cart');
1502 + }
1503 +
1021 1504 private function handleHostedSubscriptionCheckout(PaymentInstance $paymentInstance, $paymentArgs = [])
1022 1505 {
1023 1506 $order = $paymentInstance->order;
1024 1507 $transaction = $paymentInstance->transaction;
@@ -1082,9 +1565,10 @@
1082 1565 $initialAmount = 0;
1083 1566 }
1084 1567
1085 1568 $recurringTotal = (int)$subscriptionModel->recurring_total;
1086 - if ($transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency)) {
1569 + $isZeroDecimal = $transactionCurrency && CurrenciesHelper::isZeroDecimal($transactionCurrency);
1570 + if ($isZeroDecimal) {
1087 1571 $initialAmount = (int)($initialAmount / 100);
1088 1572 $recurringTotal = (int)($recurringTotal / 100);
1089 1573 }
1090 1574
@@ -1109,27 +1593,32 @@
1109 1593 if (!empty($stripePlan['trial_period_days'])) {
1110 1594 $subscriptionData['trial_period_days'] = $stripePlan['trial_period_days'];
1111 1595 }
1112 1596
1597 + // can add billing cycle anchor config here, if we allow billing anchor in fluent-cart subscription
1598 +
1113 1599 if ($initialAmount > 0) {
1114 - $addonPrice = Plan::getOneTimeAddonPrice([
1115 - 'product_id' => $subscriptionModel->product_id,
1116 - 'currency' => $order->currency,
1117 - 'amount' => (int)$initialAmount,
1118 - 'name' => __('Signup fee / initial payment', 'fluent-cart'),
1119 - 'variation_id' => $subscriptionModel->variation_id,
1120 - 'order_id' => $subscriptionModel->parent_order_id,
1600 + $stripeCurrency = strtolower($order->currency);
1121 1601
1122 - ]);
1602 + $initialItems = $this->buildHostedSubscriptionInitialLineItems(
1603 + $paymentInstance,
1604 + $stripeCurrency,
1605 + $isZeroDecimal,
1606 + (int)$initialAmount,
1607 + count($lineItems)
1608 + );
1123 1609
1124 - if (is_wp_error($addonPrice)) {
1125 - return $addonPrice;
1126 - };
1610 + if ($initialItems === null) {
1611 + $initialItems = [
1612 + $this->hostedFlatLineItem(
1613 + $this->hostedSubscriptionInitialName($order, $subscriptionModel),
1614 + $stripeCurrency,
1615 + (int)$initialAmount
1616 + )
1617 + ];
1618 + }
1127 1619
1128 - $lineItems[] = [
1129 - 'price' => $addonPrice['id'],
1130 - 'quantity' => 1
1131 - ];
1620 + $lineItems = array_merge($lineItems, $initialItems);
1132 1621 }
1133 1622
1134 1623 $sessionData = [
1135 1624 'customer' => $stripeCustomer['id'],
@@ -1139,8 +1628,11 @@
1139 1628 'consent_collection' => ['payment_method_reuse_agreement' => ['position' => 'hidden']],
1140 1629 'success_url' => Processor::getHostedGatewayReturnUrl($transaction),
1141 1630 'cancel_url' => StripeHelper::getCancelUrl(),
1142 1631 'subscription_data' => $subscriptionData,
1632 + 'saved_payment_method_options' => [
1633 + 'payment_method_save' => 'enabled'
1634 + ],
1143 1635 'metadata' => [
1144 1636 'fct_ref_id' => $order->uuid,
1145 1637 'subscription_item' => $subscriptionModel->item_name,
1146 1638 'transaction_hash' => $transaction->uuid,
@@ -1146,8 +1638,13 @@
1146 1638 'transaction_hash' => $transaction->uuid,
1147 1639 'order_reference' => 'fct_order_id_' . $order->id,
1148 1640 ],
1149 1641 ];
1642 +
1643 + $submitType = (new StripeSettingsBase())->getSubmitType();
1644 + if ($submitType && in_array($submitType, ['donate', 'subscribe', 'auto'], true)) {
1645 + $sessionData['submit_type'] = $submitType;
1646 + }
1150 1647
1151 1648 $sessionData = apply_filters('fluent_cart/payments/stripe_subscription_checkout_session_args', $sessionData, [
1152 1649 'order' => $order,
1153 1650 'transaction' => $transaction,