PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Models / OrderTransaction.php

OrderTransaction.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Models/OrderTransaction.php

334 lines 10.2 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\Models;
4
5 use FluentCart\Api\StoreSettings;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Services\DateTime\DateTime;
8 use FluentCart\App\Models\Concerns\CanSearch;
9 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
10 use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
11 use FluentCart\App\Services\Payments\PaymentHelper;
12 use FluentCart\Framework\Database\Orm\Relations\HasOne;
13 use FluentCart\Framework\Support\Arr;
14
15 /**
16 * OrderTransaction Model - DB Model for Transactions
17 *
18 * Database Model
19 *
20 * @package FluentCart\App\Models
21 *
22 * @version 1.0.0
23 */
24 class OrderTransaction extends Model
25 {
26 use CanSearch;
27
28 protected $table = 'fct_order_transactions';
29
30 protected $appends = ['url', 'refundable'];
31 /**
32 * The attributes that are mass assignable.
33 *
34 * @var array
35 */
36 protected $fillable = [
37 'order_id',
38 'order_type',
39 'vendor_charge_id',
40 'payment_method',
41 'payment_mode',
42 'payment_method_type',
43 'currency',
44 'transaction_type',
45 'subscription_id',
46 'card_last_4',
47 'card_brand',
48 'status',
49 'total',
50 'rate',
51 'meta',
52 'uuid',
53 'created_at'
54 ];
55
56 protected $searchable = [
57 'id',
58 'total',
59 'status',
60 'payment_method',
61 'currency',
62 'created_at',
63 'updated_at',
64 ];
65
66 public function setMetaAttribute($value)
67 {
68
69 if ($value) {
70 $decoded = \json_encode($value, true);
71 if (!($decoded)) {
72 $decoded = '[]';
73 }
74 } else {
75 $decoded = '[]';
76 }
77
78 $this->attributes['meta'] = $decoded;
79 }
80
81 public function getMetaAttribute($value)
82 {
83 if (!$value) {
84 return [];
85 }
86
87 return \json_decode($value, true);
88 }
89
90 public function subscription()
91 {
92 return $this->hasOne(Subscription::class, 'id', 'subscription_id');
93 }
94
95 public function order()
96 {
97 return $this->belongsTo(Order::class, 'order_id', 'id');
98 }
99
100
101 public static function boot()
102 {
103 parent::boot();
104 static::creating(function ($model) {
105 if (empty($model->uuid)) {
106 $model->uuid = md5(time() . wp_generate_uuid4());
107 }
108 });
109
110 // created_at is the checkout time, which can be weeks before the money
111 // moves (payment links, delayed webhooks) — the settlement moment is
112 // only observable at this transition, so record it here. Gateways that
113 // know the exact remote charge time set meta.settled_at themselves;
114 // this fallback never overwrites it.
115 static::saving(function ($model) {
116 if ($model->status === Status::TRANSACTION_SUCCEEDED && $model->isDirty('status')) {
117 $meta = $model->meta;
118 if (empty($meta['settled_at'])) {
119 $meta['settled_at'] = DateTime::gmtNow()->format('Y-m-d H:i:s');
120 $model->meta = $meta;
121 }
122 }
123 });
124 }
125
126 public function getUrlAttribute($value)
127 {
128
129 return apply_filters('fluent_cart/transaction/url_' . $this->getAttribute('payment_method'), '', [
130 'transaction' => $this,
131 'payment_mode' => $this->payment_mode,
132 'vendor_charge_id' => $this->vendor_charge_id,
133 'transaction_type' => $this->transaction_type
134 ]);
135 }
136
137 public function scopeOfStatus($query, $status)
138 {
139 return $query->where('status', $status);
140 }
141
142 public function scopeOfPaymentMethod($query, $methodName)
143 {
144 return $query->where('payment_method', $methodName);
145 }
146
147 public function updateStatus($newStatus, $otherData = [])
148 {
149 $oldStatus = $this->status;
150
151 if ($newStatus == $oldStatus) {
152 return $this;
153 }
154
155 $this->status = $newStatus;
156
157 if ($otherData) {
158 $this->fill($otherData);
159 }
160
161 $this->save();
162
163 return $this;
164 }
165
166 public static function bulkDeleteByOrderIds($ids, $params = [])
167 {
168 return static::getQuery()->whereIn('order_id', $ids)->delete();
169 }
170
171 public function orders(): HasOne
172 {
173 return $this->hasOne(Order::class, 'id', 'order_id');
174 }
175
176 public function getRefundableAttribute(): int
177 {
178 return $this->getMaxRefundableAmount();
179 }
180
181 public function getMaxRefundableAmount()
182 {
183 if ($this->status !== Status::TRANSACTION_SUCCEEDED) {
184 return 0;
185 }
186 $refundAmount = (int)(Arr::get($this->meta, 'refunded_total', 0));
187 $baseAmount = $this->total - $refundAmount;
188 $filtered = apply_filters('fluent_cart/transaction/max_refundable_amount', $baseAmount, $this);
189 return max(0, min((int) $filtered, $baseAmount));
190 }
191
192 public function getPaymentMethodText()
193 {
194 if ($this->card_brand && $this->card_last_4) {
195 return sprintf('%1$s ***%2$s', esc_html($this->card_brand), esc_html($this->card_last_4));
196 }
197
198 return $this->payment_method;
199 }
200
201 /**
202 * $filtered defaults to true because every caller redirects the customer and
203 * therefore wants the filter. It defaulted to false, so a caller that omitted
204 * the argument silently got an unfilterable URL — which is how the Stripe
205 * confirmation redirect lost its filter in a refactor. The parameter is kept
206 * so an explicit `false` can still ask for the raw URL.
207 */
208 public function getReceiptPageUrl($filtered = true)
209 {
210 $url = add_query_arg([
211 'trx_hash' => $this->uuid
212 ], (new StoreSettings())->getReceiptPage());
213
214 if ($filtered) {
215 $context = [
216 'transaction' => $this,
217 'order' => $this->order,
218 ];
219 $url = apply_filters('fluent_cart/transaction/receipt_page_url', $url, $context);
220 }
221
222 return $url;
223 }
224
225 /**
226 * Canonical post-payment redirect URL for this transaction.
227 * Always fires the fluent_cart/payment/success_url filter.
228 *
229 * @param array $args Extra query args merged into the URL
230 * @return string
231 */
232 public function getSuccessUrl($args = [])
233 {
234 return (new PaymentHelper((string)$this->payment_method))->successUrl($this, $args);
235 }
236
237 public function syncPendingTransaction()
238 {
239 if ($this->transaction_type !== Status::TRANSACTION_TYPE_CHARGE) {
240 return new \WP_Error('invalid_transaction_type', __('Only charge transactions can be synced from the payment gateway.', 'fluent-cart'));
241 }
242
243 if ($this->status !== Status::TRANSACTION_PENDING) {
244 return new \WP_Error('invalid_transaction_status', __('Only pending transactions can be synced from the payment gateway.', 'fluent-cart'));
245 }
246
247 if (!$this->vendor_charge_id) {
248 return new \WP_Error('missing_vendor_charge_id', __('This transaction has no gateway charge ID to sync against.', 'fluent-cart'));
249 }
250
251 $gateway = GatewayManager::getInstance($this->payment_method);
252 if ($gateway instanceof AbstractPaymentGateway) {
253 return $gateway->syncRemoteTransaction($this);
254 }
255
256 return new \WP_Error('invalid_payment_method', __('This payment method does not support remote transaction sync', 'fluent-cart'));
257 }
258
259 public function acceptDispute($args = [])
260 {
261 if ($this->transaction_type !== Status::TRANSACTION_TYPE_DISPUTE) {
262 return new \WP_Error('No dispute found!', __('The selected transaction is not a dispute', 'fluent-cart'));
263 }
264
265 $gateway = GatewayManager::getInstance($this->payment_method);
266
267 if ($gateway && $gateway->has('dispute_handler')) {
268 $handleRemoteDispute = $gateway->acceptRemoteDispute($this, $args);
269 if (is_wp_error($handleRemoteDispute)) {
270 return $handleRemoteDispute;
271 }
272
273 $this->status = Status::TRANSACTION_DISPUTE_LOST;
274 $this->meta = array_merge($this->meta, [
275 'is_dispute_actionable' => false,
276 'is_charge_refundable' => false
277 ]);
278 $this->save();
279
280 $newPaidAmount = intval($this->order->total_paid - $this->total);
281 $this->order->update([
282 'total_paid' => max($newPaidAmount, 0),
283 'payment_status' => $newPaidAmount > 0 ? Status::PAYMENT_PARTIALLY_PAID : Status::PAYMENT_FAILED,
284 ]);
285
286 if (Arr::get($args, 'dispute_note')) {
287 $this->meta = array_merge($this->meta, [
288 'dispute_note' => $args['dispute_note'],
289 ]);
290 $this->save();
291 }
292
293 fluent_cart_add_log(
294 'Dispute accepted on ' . $this->payment_method,
295 'Dispute accepted! ' . $args['dispute_note'] ?? 'Note: ' . Arr::get($args, 'dispute_note'), 'success', [
296 'module_id' => $this->order->id,
297 'module_name' => 'order',
298 ]
299 );
300 } else {
301 return new \WP_Error('invalid_payment_method', __('This payment method does not support remote dispute management', 'fluent-cart'));
302 }
303 }
304
305 public function scopeSearchByPayerEmail ($query, $data) {
306
307 $operator = Arr::get($data, 'operator', 'contains');
308
309 $search = Arr::get($data, 'value');
310 $search = sanitize_text_field(trim($search));
311
312 switch ($operator) {
313 case 'starts_with':
314 $pattern = $search . '%';
315 break;
316 case 'ends_with':
317 $pattern = '%' . $search;
318 break;
319 case 'equals':
320 $pattern = $search;
321 break;
322 case 'not_like':
323 return $query->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.payer.email_address')) NOT LIKE ?", ['%' . $search . '%']);
324 default: // contains
325 $pattern = '%' . $search . '%';
326 break;
327 }
328
329 return $query->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.payer.email_address')) LIKE ?", [$pattern]);
330
331 }
332
333 }
334