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/PayPalGateway/PayPalHelper.php +86 -9 1.3.22 → 1.6.6 View file →
@@ -13,8 +13,74 @@
13 13
14 14 class PayPalHelper
15 15 {
16 16 /**
17 + * Currencies PayPal rejects a decimal amount for.
18 + *
19 + * "This currency does not support decimals. If you pass a decimal amount,
20 + * an error occurs." — developer.paypal.com/api/rest/reference/currency-codes
21 + *
22 + * This is PayPal's own list, not ISO 4217 and not the store-wide
23 + * CurrenciesHelper::zeroDecimalCurrencies() set — internal storage stays
24 + * x100 for every currency, only what PayPal accepts differs.
25 + */
26 + const ZERO_DECIMAL_CURRENCIES = ['HUF', 'JPY', 'TWD'];
27 +
28 + public static function currencyDecimals($currency): int
29 + {
30 + return in_array(strtoupper((string) $currency), self::ZERO_DECIMAL_CURRENCIES, true) ? 0 : 2;
31 + }
32 +
33 + /**
34 + * Cents to a decimal amount, rounded to the precision PayPal accepts for
35 + * the currency. Amount arithmetic (breakdown sums, discount/adjustment
36 + * reconciliation) must run on these rounded values so the breakdown still
37 + * adds up to the total once every part is formatted.
38 + */
39 + public static function toDecimalAmount($amountInCents, $currency)
40 + {
41 + if (!is_numeric($amountInCents)) {
42 + return 0;
43 + }
44 +
45 + return round(floatval($amountInCents) / 100, self::currencyDecimals($currency));
46 + }
47 +
48 + /**
49 + * Format an already-converted decimal amount for the API payload.
50 + */
51 + public static function formatDecimalAmount($amount, $currency): string
52 + {
53 + return number_format(floatval($amount), self::currencyDecimals($currency), '.', '');
54 + }
55 +
56 + public static function formatAmount($amountInCents, $currency): string
57 + {
58 + return self::formatDecimalAmount(self::toDecimalAmount($amountInCents, $currency), $currency);
59 + }
60 +
61 + /**
62 + * The cents PayPal will actually move for a stored amount.
63 + *
64 + * Storage stays x100 for every currency, so a zero-decimal amount can carry
65 + * a residue PayPal cannot charge: JPY 100050 goes on the wire as "1001" and
66 + * comes back through Helper::toCent() as 100100. Every equality check
67 + * against a PayPal-reported amount compares to this, never to the raw
68 + * stored total, or a correct payment reads as tampering. Identity for
69 + * 2-decimal currencies.
70 + *
71 + * Routed through formatAmount() and Helper::toCent() rather than
72 + * recomputing the arithmetic, so this is the same serialize-then-read-back
73 + * path the payload and the gateway's reply actually travel. Recomputing it
74 + * diverges from that path above ~1e15 cents, where number_format() still
75 + * moves a value round() has stopped changing.
76 + */
77 + public static function wireCents($amountInCents, $currency): int
78 + {
79 + return Helper::toCent(self::formatAmount($amountInCents, $currency));
80 + }
81 +
82 + /**
17 83 * Get or create a Stripe pricing plan for a product variation.
18 84 *
19 85 * @param array $data {
20 86 * @type string $product_id Product ID.
@@ -111,9 +177,9 @@
111 177
112 178 $refundData = [
113 179 'custom_id' => $transaction->uuid,
114 180 'amount' => array(
115 - 'value' => Helper::toDecimalWithoutComma($amount),
181 + 'value' => self::formatAmount($amount, $transaction->currency),
116 182 'currency_code' => $transaction->currency
117 183 ),
118 184
119 185 ];
@@ -203,12 +269,16 @@
203 269
204 270 $data['product_id'] = $data['paypal_product_id'];
205 271 $data['recurring_total'] = $data['recurring_amount'];
206 272
207 - // Convert amounts from cents to dollars with 2 decimal places
208 - $recurring_amount = number_format($data['recurring_total'] / 100, 2, '.', '');
209 - $initial_amount = $data['signup_fee'] > 0 ? number_format($data['signup_fee'] / 100, 2, '.', '') : 0;
273 + // Convert amounts from cents to the currency's decimal precision
274 + $recurringCents = (int) $data['recurring_total'];
275 + $signupFeeCents = (int) $data['signup_fee'];
210 276
277 + $recurringAmount = self::toDecimalAmount($recurringCents, $data['currency']);
278 + $initialAmount = $signupFeeCents > 0 ? self::toDecimalAmount($signupFeeCents, $data['currency']) : 0;
279 + $hasSignupFee = $signupFeeCents > 0;
280 +
211 281 // Map billing interval to PayPal API interval unit
212 282 $interval_map = [
213 283 Status::BILLING_MONTHLY => 'MONTH',
214 284 Status::BILLING_QUARTERLY => 'MONTH',
@@ -269,9 +339,9 @@
269 339 'sequence' => 1,
270 340 'total_cycles' => $data['bill_times'],
271 341 'pricing_scheme' => [
272 342 'fixed_price' => [
273 - 'value' => $recurring_amount,
343 + 'value' => self::formatDecimalAmount($recurringAmount, $data['currency']),
274 344 'currency_code' => $data['currency']
275 345 ]
276 346 ]
277 347 ];
@@ -287,9 +357,9 @@
287 357 ],
288 358 'sequence' => 1,
289 359 'pricing_scheme' => [
290 360 'fixed_price' => [
291 - 'value' => '0.00',
361 + 'value' => self::formatDecimalAmount(0, $data['currency']),
292 362 'currency_code' => $data['currency']
293 363 ]
294 364 ],
295 365 'total_cycles' => 1
@@ -296,11 +366,11 @@
296 366 ];
297 367 $normalCycle['sequence'] = 2; // If there's a trial, the regular cycle sequence should be 2
298 368 }
299 369
300 - if ($initial_amount) {
370 + if ($hasSignupFee) {
301 371 if ($trialCycle) {
302 - $trialCycle['pricing_scheme']['fixed_price']['value'] = $initial_amount;
372 + $trialCycle['pricing_scheme']['fixed_price']['value'] = self::formatDecimalAmount($initialAmount, $data['currency']);
303 373 } else {
304 374 $trialCycle = [
305 375 'tenure_type' => 'TRIAL',
306 376 'frequency' => [
@@ -309,9 +379,16 @@
309 379 ],
310 380 'sequence' => 1,
311 381 'pricing_scheme' => [
312 382 'fixed_price' => [
313 - 'value' => $initial_amount + $recurring_amount,
383 + // Sum the cents, then round once. Rounding the fee and the
384 + // recurring price separately overcharges by up to one minor
385 + // unit on a zero-decimal currency, and drops a fee that is
386 + // smaller than one.
387 + 'value' => self::formatDecimalAmount(
388 + self::toDecimalAmount($recurringCents + $signupFeeCents, $data['currency']),
389 + $data['currency']
390 + ),
314 391 'currency_code' => $data['currency']
315 392 ]
316 393 ],
317 394 'total_cycles' => 1