← All changes
|
app/Modules/PaymentMethods/PayPalGateway/PayPalHelper.php
+86
-9
1.5.2
→
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 |