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
fluent-cart / app / Modules / PaymentMethods / PayPalGateway / PayPalHelper.php

PayPalHelper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/PaymentMethods/PayPalGateway/PayPalHelper.php

415 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\PayPalGateway;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Models\Product;
9 use FluentCart\App\Models\ProductVariation;
10 use FluentCart\App\Modules\PaymentMethods\PayPalGateway\API\API;
11 use FluentCart\App\Services\ProductItemService;
12 use FluentCart\Framework\Support\Arr;
13
14 class PayPalHelper
15 {
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 /**
83 * Get or create a Stripe pricing plan for a product variation.
84 *
85 * @param array $data {
86 * @type string $product_id Product ID.
87 * @type string $variation_id Variation ID.
88 * @type string $trial_days Trial Days.
89 * @type string $billing_interval Billing interval (e.g., 'month', 'year').
90 * @type string $currency Currency code (e.g., 'usd').
91 * @type int $interval_count Number of intervals.
92 * @type int $recurring_amount Recurring total amount (in cents).
93 * @type int $signup_fee Recurring total amount (in cents).
94 * @type int bill_times optional 0 for continuous billing, or a specific number of billing cycles
95 * }
96 *
97 * @return \WP_Error|array
98 */
99 public static function getPayPalPlan($data = [])
100 {
101 $item = ProductItemService::getItem($data);
102 $product = $item->product;
103 $variation = $item->variation;
104
105 if (!$variation || !$product) {
106 return new \WP_Error('invalid_product', esc_html__('Invalid product or variation.', 'fluent-cart'));
107 }
108 $sitePrefix = Helper::getSitePrefix();
109
110 $planId = 'fct_pp_plan_'
111 . $data['currency'] . '_'
112 . $data['variation_id'] . '_'
113 . $data['recurring_amount'] . '_'
114 . $data['billing_interval'] . '_'
115 . $data['interval_count'] . '_'
116 . $data['trial_days'] . '_'
117 . $data['signup_fee'] . '_'
118 . $data['bill_times'];
119
120 $planId = apply_filters('fluent_cart/paypal_plan_id', $planId, [
121 'plan_data' => $data,
122 'variation' => $variation,
123 'product' => $product
124 ]);
125
126 $paypalPlan = null;
127 if ($product && $product instanceof Product) {
128 $paypalPlanId = $product->getProductMeta($planId);
129
130 if ($paypalPlanId) {
131 $paypalPlan = API::getResource('billing/plans/' . $paypalPlanId);
132 if (!is_wp_error($paypalPlan)) {
133 return $paypalPlan;
134 }
135 }
136 }
137
138 $paypalProduct = self::getRemoteProductByData([
139 'id' => 'prod_' . $data['product_id'] . '_' . $sitePrefix,
140 'name' => sanitize_text_field($product->post_title),
141 'description' => sanitize_text_field($product->post_excerpt)
142 ]);
143
144 if (is_wp_error($paypalProduct)) {
145 return $paypalProduct;
146 }
147
148 $planData = wp_parse_args($data, [
149 'plan_name' => $product->post_title . ' - ' . $variation->variation_title,
150 'plan_description' => $product->post_title,
151 'paypal_product_id' => $paypalProduct['id'],
152 ]);
153
154 $paypalPlanArgs = self::getPayPalPlanArgs($planData);
155
156 $paypalPlan = API::createResource('billing/plans', $paypalPlanArgs);
157
158 if (is_wp_error($paypalPlan)) {
159 return $paypalPlan;
160 }
161
162
163 if ($product && $product instanceof Product) {
164 $product->updateProductMeta($planId, $paypalPlan['id']);
165 }
166
167 return $paypalPlan;
168 }
169
170 public static function processRemoteRefund($transaction, $amount, $args)
171 {
172 $chargeId = $transaction->vendor_charge_id;
173
174 if (!$chargeId) {
175 return new \WP_Error('invalid_charge_id', __('Please provide a valid charge Id!', 'fluent-cart'));
176 }
177
178 $refundData = [
179 'custom_id' => $transaction->uuid,
180 'amount' => array(
181 'value' => self::formatAmount($amount, $transaction->currency),
182 'currency_code' => $transaction->currency
183 ),
184
185 ];
186
187
188 $reason = Arr::get($args, 'reason', '');
189 if ($reason) {
190 $refundData['note_to_payer'] = substr($reason, 0, 255);
191 }
192
193 $vendorRefund = (new API())->makeRequest('payments/captures/' . $chargeId . '/refund', 'v2', 'POST', $refundData);
194
195 if (is_wp_error($vendorRefund)) {
196 return $vendorRefund;
197 }
198
199 if (Arr::get($vendorRefund, 'status') !== 'COMPLETED') {
200 return new \WP_Error('refund_not_completed', __('Refund not completed in paypal. Please refund from PayPal Manually.', 'fluent-cart'));
201 }
202
203 return Arr::get($vendorRefund, 'id');
204 }
205
206 private static function getRemoteProductByData($productData = [])
207 {
208
209 if (isset($productData['id']) && strlen($productData['id']) > 50) {
210 $productData['id'] = substr($productData['id'], 0, 48); // PayPal product ID should be less than 50 characters
211 }
212
213 if (isset($productData['id'])) {
214 $existingProduct = API::getResource('catalogs/products/' . $productData['id']);
215 if (!is_wp_error($existingProduct)) {
216 return $existingProduct;
217 }
218 }
219
220
221 $formattedData = [
222 'id' => Arr::get($productData, 'id'),
223 'name' => Arr::get($productData, 'name'),
224 'description' => Arr::get($productData, 'description'),
225 'type' => Arr::get($productData, 'type'),
226 ];
227
228 $validTypes = ['PHYSICAL', 'DIGITAL', 'SERVICE'];
229 if (!in_array($formattedData['type'], $validTypes)) {
230 $formattedData['type'] = 'DIGITAL'; // Default to PHYSICAL if type is not valid
231 }
232
233 // name should be under 127 characters, add '...' if it is more than 127 characters
234 if ($formattedData['name'] && strlen($formattedData['name']) > 127) {
235 $formattedData['name'] = substr($formattedData['name'], 0, 120) . '...';
236 }
237
238 // description should be under 256 characters, add '...' if it is more than 256 characters
239 if ($formattedData['description'] && strlen($formattedData['description']) > 256) {
240 $formattedData['description'] = substr($formattedData['description'], 0, 250) . '...';
241 }
242
243 $formattedData = array_filter($formattedData);
244
245 $createdProduct = API::createResource('catalogs/products', $formattedData);
246
247
248 return $createdProduct;
249 }
250
251 private static function getPayPalPlanArgs($data = [])
252 {
253 // Default values
254 $defaults = [
255 'paypal_product_id' => '',
256 'trial_days' => 0,
257 'billing_interval' => 'monthly',
258 'currency' => 'USD',
259 'interval_count' => 1,
260 'recurring_amount' => 0,
261 'signup_fee' => 0,
262 'bill_times' => 0,
263 'plan_name' => __('Subscription', 'fluent-cart'),
264 'plan_description' => __('Subscription Plan', 'fluent-cart')
265 ];
266
267 // Merge input data with defaults
268 $data = wp_parse_args($data, $defaults);
269
270 $data['product_id'] = $data['paypal_product_id'];
271 $data['recurring_total'] = $data['recurring_amount'];
272
273 // Convert amounts from cents to the currency's decimal precision
274 $recurringCents = (int) $data['recurring_total'];
275 $signupFeeCents = (int) $data['signup_fee'];
276
277 $recurringAmount = self::toDecimalAmount($recurringCents, $data['currency']);
278 $initialAmount = $signupFeeCents > 0 ? self::toDecimalAmount($signupFeeCents, $data['currency']) : 0;
279 $hasSignupFee = $signupFeeCents > 0;
280
281 // Map billing interval to PayPal API interval unit
282 $interval_map = [
283 Status::BILLING_MONTHLY => 'MONTH',
284 Status::BILLING_QUARTERLY => 'MONTH',
285 Status::BILLING_HALF_YEARLY => 'MONTH',
286 Status::BILLING_YEARLY => 'YEAR',
287 Status::BILLING_WEEKLY => 'WEEK',
288 Status::BILLING_DAILY => 'DAY'
289 ];
290
291 $interval_unit = isset($interval_map[$data['billing_interval']]) ? $interval_map[$data['billing_interval']] : 'MONTH';
292
293 // Determine interval_count based on billing_interval
294 $intervalCount = 1;
295 if ($data['billing_interval'] === Status::BILLING_QUARTERLY) {
296 $intervalCount = 3;
297 } elseif ($data['billing_interval'] === Status::BILLING_HALF_YEARLY) {
298 $intervalCount = 6;
299 }
300
301 $intervalCount = 1;
302 if ($data['billing_interval'] === Status::BILLING_QUARTERLY) {
303 $intervalCount = 3;
304 } elseif ($data['billing_interval'] === Status::BILLING_HALF_YEARLY) {
305 $intervalCount = 6;
306 }
307
308 $billingPeriod = [
309 'interval_unit' => $interval_unit,
310 'interval_frequency' => $intervalCount,
311 ];
312
313 $billingPeriod = apply_filters('fluent_cart/subscription_billing_period', $billingPeriod, [
314 'subscription_interval' => $data['billing_interval'],
315 'payment_method' => 'paypal',
316 ]);
317
318 // Base plan structure
319 $plan_name = $data['plan_name'];
320 $plan_description = $data['plan_description'];
321
322 $paypal_plan = [
323 'product_id' => $data['product_id'],
324 'name' => $plan_name,
325 'description' => $plan_description,
326 'status' => 'ACTIVE',
327 'payment_preferences' => [
328 'auto_bill_outstanding' => true,
329 'payment_failure_threshold' => 3
330 ]
331 ];
332
333 $normalCycle = [
334 'frequency' => [
335 'interval_unit' => Arr::get($billingPeriod, 'interval_unit'),
336 'interval_count' => Arr::get($billingPeriod, 'interval_frequency'),
337 ],
338 'tenure_type' => 'REGULAR',
339 'sequence' => 1,
340 'total_cycles' => $data['bill_times'],
341 'pricing_scheme' => [
342 'fixed_price' => [
343 'value' => self::formatDecimalAmount($recurringAmount, $data['currency']),
344 'currency_code' => $data['currency']
345 ]
346 ]
347 ];
348
349 $trialCycle = [];
350
351 if ($data['trial_days'] > 0) {
352 $trialCycle = [
353 'tenure_type' => 'TRIAL',
354 'frequency' => [
355 'interval_unit' => 'DAY',
356 'interval_count' => $data['trial_days']
357 ],
358 'sequence' => 1,
359 'pricing_scheme' => [
360 'fixed_price' => [
361 'value' => self::formatDecimalAmount(0, $data['currency']),
362 'currency_code' => $data['currency']
363 ]
364 ],
365 'total_cycles' => 1
366 ];
367 $normalCycle['sequence'] = 2; // If there's a trial, the regular cycle sequence should be 2
368 }
369
370 if ($hasSignupFee) {
371 if ($trialCycle) {
372 $trialCycle['pricing_scheme']['fixed_price']['value'] = self::formatDecimalAmount($initialAmount, $data['currency']);
373 } else {
374 $trialCycle = [
375 'tenure_type' => 'TRIAL',
376 'frequency' => [
377 'interval_unit' => $interval_unit,
378 'interval_count' => 1
379 ],
380 'sequence' => 1,
381 'pricing_scheme' => [
382 'fixed_price' => [
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 ),
391 'currency_code' => $data['currency']
392 ]
393 ],
394 'total_cycles' => 1
395 ];
396
397 $normalCycle['total_cycles'] = $data['bill_times'] > 1 ? $data['bill_times'] - 1 : $data['bill_times']; // this trial cycle is without trial days, it's an adjusted paid trial cycle to take signupFee with one transaction
398 $normalCycle['sequence'] = 2; // If there's a trial, the regular cycle sequence should be 2
399 }
400 }
401
402 $cycles = [];
403
404 if ($trialCycle) {
405 $cycles[] = $trialCycle;
406 }
407
408 $cycles[] = $normalCycle;
409
410 $paypal_plan['billing_cycles'] = $cycles;
411
412 return $paypal_plan;
413 }
414 }
415