Compare commits

...

1 Commits

Author SHA1 Message Date
Ollama
4d8e2442b5 feat: Add payment provider plugin system for external gateway integrations
This commit introduces a comprehensive payment provider architecture to enable
seamless integration with external payment gateways like SumUp and PayPal/Zettle.

Architecture:
- PaymentProviderInterface: Contract for all payment providers
- PaymentProviderBase: Abstract base class with common functionality
- PaymentProviderRegistry: Singleton registry for provider management
- PaymentTransaction model: Transaction tracking and status management

Infrastructure:
- Webhook controller: Endpoint for external payment callbacks
- Payment events: payment_initiated, payment_completed, sale_completed
- payment_helper.php: Helper functions for payment provider content
- Migration for ospos_payment_transactions table

Core changes:
- Add Events::trigger('payment_options') in locale_helper.php
- Add Events::trigger('sale_completed') in Sales controller
- Add Events::trigger('payment_initiated') in postAddPayment()
- Add webhook routes for /payments/webhook/{provider}

Provider stubs:
- SumUpProvider: Card reader terminal integration
- PayPalProvider: Card reader and QR code payment integration

Related issues: #4346, #4322, #3232, #3789, #3790, #2275
2026-04-01 21:29:45 +00:00
14 changed files with 1033 additions and 0 deletions

View File

@@ -11,6 +11,10 @@ $routes->get('/', 'Login::index');
$routes->get('login', 'Login::index');
$routes->post('login', 'Login::index');
// Payment provider webhook routes (no authentication required)
$routes->post('payments/webhook/(:segment)', 'Payments\Webhook::handle/$1');
$routes->get('payments/status/(:segment)/(:segment)', 'Payments\Webhook::status/$1/$2');
$routes->add('no_access/index/(:segment)', 'No_access::index/$1');
$routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2');

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Controllers\Payments;
use App\Controllers\BaseController;
use App\Libraries\Payments\PaymentProviderRegistry;
use CodeIgniter\HTTP\ResponseInterface;
class Webhook extends BaseController
{
public function handle(string $providerId): ResponseInterface
{
$provider = PaymentProviderRegistry::getInstance()->getProvider($providerId);
if ($provider === null) {
log_message('error', "Webhook received for unknown provider: {$providerId}");
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Provider not found'
]);
}
$rawInput = $this->request->getBody();
$data = json_decode($rawInput, true) ?? [];
if (empty($rawInput)) {
$data = $this->request->getPost();
}
try {
$result = $provider->processCallback($data);
if ($result['success'] ?? false) {
log_message('info', "Webhook processed successfully for provider: {$providerId}", $result);
return $this->response->setStatusCode(200)->setJSON($result);
}
log_message('warning', "Webhook processing failed for provider: {$providerId}", $result);
return $this->response->setStatusCode(400)->setJSON($result);
} catch (\Exception $e) {
log_message('error', "Webhook exception for provider {$providerId}: " . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Internal server error'
]);
}
}
public function status(string $providerId, string $transactionId): ResponseInterface
{
$provider = PaymentProviderRegistry::getInstance()->getProvider($providerId);
if ($provider === null) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Provider not found'
]);
}
try {
$result = $provider->getPaymentStatus($transactionId);
return $this->response->setStatusCode(200)->setJSON($result);
} catch (\Exception $e) {
log_message('error', "Status check exception for provider {$providerId}: " . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Internal server error'
]);
}
}
}

View File

@@ -20,6 +20,7 @@ use App\Models\Stock_location;
use App\Models\Tokens\Token_invoice_count;
use App\Models\Tokens\Token_customer;
use App\Models\Tokens\Token_invoice_sequence;
use CodeIgniter\Events\Events;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use Config\OSPOS;
@@ -471,6 +472,13 @@ class Sales extends Secure_Controller
}
}
Events::trigger('payment_initiated', [
'payment_type' => $payment_type,
'amount' => $amount_tendered ?? 0,
'sale_id' => $this->sale_lib->get_sale_id(),
'customer_id' => $this->sale_lib->get_customer(),
]);
return $this->_reload($data);
}
@@ -786,6 +794,16 @@ class Sales extends Secure_Controller
$data['error_message'] = lang('Sales.transaction_failed');
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
Events::trigger('sale_completed', [
'sale_id' => $data['sale_id_num'],
'customer_id' => $customer_id,
'employee_id' => $employee_id,
'total' => $data['total'],
'payments' => $data['payments'],
'sale_type' => $sale_type,
]);
$this->sale_lib->clear_all();
return view('sales/' . $invoice_view, $data);
}
@@ -869,6 +887,16 @@ class Sales extends Secure_Controller
$data['error_message'] = lang('Sales.transaction_failed');
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
Events::trigger('sale_completed', [
'sale_id' => $data['sale_id_num'],
'customer_id' => $customer_id,
'employee_id' => $employee_id,
'total' => $data['total'],
'payments' => $data['payments'],
'sale_type' => $sale_type,
]);
$this->sale_lib->clear_all();
return view('sales/receipt', $data);
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class Migration_PaymentTransactions extends Migration
{
public function up(): void
{
$forge = \Config\Services::forge();
$forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true
],
'provider_id' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => false
],
'sale_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true
],
'transaction_id' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => false
],
'amount' => [
'type' => 'DECIMAL',
'constraint' => '15,2',
'null' => false
],
'currency' => [
'type' => 'VARCHAR',
'constraint' => 3,
'default' => 'USD',
'null' => false
],
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'authorized', 'completed', 'failed', 'refunded', 'cancelled'],
'default' => 'pending',
'null' => false
],
'metadata' => [
'type' => 'JSON',
'null' => true
],
'created_at' => [
'type' => 'TIMESTAMP',
'null' => true
],
'updated_at' => [
'type' => 'TIMESTAMP',
'null' => true
]
]);
$forge->addKey('id', true);
$forge->addKey('provider_id');
$forge->addKey('sale_id');
$forge->addKey('transaction_id');
$forge->addKey('status');
$forge->createTable('payment_transactions', true);
}
public function down(): void
{
$forge = \Config\Services::forge();
$forge->dropTable('payment_transactions', true);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Events;
use App\Libraries\Payments\PaymentProviderRegistry;
use CodeIgniter\Events\Events;
use Config\Services;
class PaymentEvents
{
public static function initialize(): void
{
Events::on('payment_initiated', [static::class, 'onPaymentInitiated']);
Events::on('payment_completed', [static::class, 'onPaymentCompleted']);
Events::on('payment_failed', [static::class, 'onPaymentFailed']);
Events::on('sale_completed', [static::class, 'onSaleCompleted']);
}
public static function onPaymentInitiated(array $data): void
{
log_message('debug', sprintf(
'Payment initiated: type=%s, amount=%s, sale_id=%s',
$data['payment_type'] ?? 'unknown',
$data['amount'] ?? 0,
$data['sale_id'] ?? 'pending'
));
}
public static function onPaymentCompleted(array $data): void
{
log_message('debug', sprintf(
'Payment completed: type=%s, amount=%s, sale_id=%s',
$data['payment_type'] ?? 'unknown',
$data['amount'] ?? 0,
$data['sale_id'] ?? 'pending'
));
}
public static function onPaymentFailed(array $data): void
{
log_message('warning', sprintf(
'Payment failed: type=%s, amount=%s, error=%s',
$data['payment_type'] ?? 'unknown',
$data['amount'] ?? 0,
$data['error'] ?? 'unknown error'
));
}
public static function onSaleCompleted(array $data): void
{
log_message('info', sprintf(
'Sale completed: sale_id=%s, total=%s, payments=%s',
$data['sale_id'] ?? 'unknown',
$data['total'] ?? 0,
json_encode($data['payments'] ?? [])
));
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Models\Employee;
use CodeIgniter\Events\Events;
use Config\OSPOS;
/**
@@ -276,6 +277,12 @@ function get_payment_options(): array
$payments[lang('Sales.upi')] = lang('Sales.upi');
}
// Allow payment provider plugins to add additional payment options
$eventPayments = Events::trigger('payment_options', $payments);
if (is_array($eventPayments)) {
return $eventPayments;
}
return $payments;
}

View File

@@ -0,0 +1,64 @@
<?php
use App\Libraries\Payments\PaymentProviderRegistry;
use CodeIgniter\Events\Events;
if (!function_exists('register_payment_provider')) {
function register_payment_provider(App\Libraries\Payments\PaymentProviderInterface $provider): void
{
PaymentProviderRegistry::getInstance()->register($provider);
}
}
if (!function_exists('get_payment_providers')) {
function get_payment_providers(): array
{
return PaymentProviderRegistry::getInstance()->getProviders();
}
}
if (!function_exists('get_enabled_payment_providers')) {
function get_enabled_payment_providers(): array
{
return PaymentProviderRegistry::getInstance()->getEnabledProviders();
}
}
if (!function_exists('get_enabled_payment_types')) {
function get_enabled_payment_types(): array
{
return PaymentProviderRegistry::getInstance()->getEnabledPaymentTypes();
}
}
if (!function_exists('get_payment_provider')) {
function get_payment_provider(string $providerId): ?App\Libraries\Payments\PaymentProviderInterface
{
return PaymentProviderRegistry::getInstance()->getProvider($providerId);
}
}
if (!function_exists('get_payment_provider_for_type')) {
function get_payment_provider_for_type(string $paymentTypeKey): ?App\Libraries\Payments\PaymentProviderInterface
{
return PaymentProviderRegistry::getInstance()->getProviderForPaymentType($paymentTypeKey);
}
}
if (!function_exists('payment_provider_content')) {
function payment_provider_content(string $section, array $data = []): string
{
$results = Events::trigger("payment_view:{$section}", $data);
$output = '';
if (is_array($results)) {
foreach ($results as $result) {
if (is_string($result)) {
$output .= $result;
}
}
} elseif (is_string($results)) {
$output = $results;
}
return $output;
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Libraries\Payments;
use CodeIgniter\Events\Events;
abstract class PaymentProviderBase implements PaymentProviderInterface
{
protected array $config = [];
public function __construct(array $config = [])
{
$this->config = $config;
}
public function getProviderId(): string
{
return static::class;
}
public function getVersion(): string
{
return '1.0.0';
}
public function getProviderDescription(): string
{
return '';
}
public function getPaymentTypes(): array
{
return [];
}
public function getIcon(?string $paymentType = null): ?string
{
return null;
}
public function getConfigView(): ?string
{
return null;
}
public function isAvailable(): bool
{
$settings = $this->getSettings();
return !empty($settings['enabled']) && $settings['enabled'] === '1';
}
public function getSettings(): array
{
$settingsModel = model(\App\Models\Appconfig::class);
$prefix = $this->getSettingsPrefix();
$settings = [];
$result = $settingsModel->like('key', $prefix . '_')->findAll();
foreach ($result as $row) {
$key = str_replace($prefix . '_', '', $row['key']);
$settings[$key] = $row['value'];
}
return $settings;
}
public function saveSettings(array $settings): bool
{
$settingsModel = model(\App\Models\Appconfig::class);
$prefix = $this->getSettingsPrefix();
foreach ($settings as $key => $value) {
$fullKey = $prefix . '_' . $key;
$settingsModel->save(['key' => $fullKey, 'value' => $value]);
}
return true;
}
protected function getSettingsPrefix(): string
{
return 'payment_' . $this->getProviderId();
}
protected function logTransaction(
string $transactionId,
string $status,
float $amount,
string $currency = 'USD',
?int $saleId = null,
array $metadata = []
): bool {
$transactionModel = model(\App\Models\PaymentTransaction::class);
$data = [
'provider_id' => $this->getProviderId(),
'transaction_id' => $transactionId,
'status' => $status,
'amount' => $amount,
'currency' => $currency,
'sale_id' => $saleId,
'metadata' => json_encode($metadata),
];
return $transactionModel->insert($data) !== false;
}
protected function updateTransactionStatus(string $transactionId, string $status, array $metadata = []): bool
{
$transactionModel = model(\App\Models\PaymentTransaction::class);
$transaction = $transactionModel
->where('transaction_id', $transactionId)
->where('provider_id', $this->getProviderId())
->first();
if (!$transaction) {
return false;
}
$updateData = ['status' => $status];
if (!empty($metadata)) {
$existingMetadata = json_decode($transaction['metadata'] ?? '{}', true);
$updateData['metadata'] = json_encode(array_merge($existingMetadata, $metadata));
}
return $transactionModel->update($transaction['id'], $updateData);
}
protected function getTransaction(string $transactionId): ?array
{
$transactionModel = model(\App\Models\PaymentTransaction::class);
return $transactionModel
->where('transaction_id', $transactionId)
->where('provider_id', $this->getProviderId())
->first();
}
protected function getSetting(string $key, mixed $default = null): mixed
{
$settings = $this->getSettings();
return $settings[$key] ?? $default;
}
protected function fire(string $event, array $data = []): void
{
Events::trigger("payment_{$event}", array_merge($data, ['provider' => $this->getProviderId()]));
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Libraries\Payments;
interface PaymentProviderInterface
{
public function getProviderId(): string;
public function getProviderName(): string;
public function getProviderDescription(): string;
public function getVersion(): string;
public function getPaymentTypes(): array;
public function getIcon(?string $paymentType = null): ?string;
public function initiatePayment(float $amount, string $currency, array $options = []): array;
public function processCallback(array $data): array;
public function getPaymentStatus(string $transactionId): array;
public function refund(string $transactionId, float $amount, string $reason = ''): array;
public function cancel(string $transactionId): array;
public function isAvailable(): bool;
public function getSettings(): array;
public function saveSettings(array $settings): bool;
public function getConfigView(): ?string;
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Libraries\Payments;
class PaymentProviderRegistry
{
private static ?PaymentProviderRegistry $instance = null;
private array $providers = [];
private bool $initialized = false;
private function __construct()
{
}
public static function getInstance(): PaymentProviderRegistry
{
if (self::$instance === null) {
self::$instance = new PaymentProviderRegistry();
}
return self::$instance;
}
public function register(PaymentProviderInterface $provider): void
{
$providerId = $provider->getProviderId();
if (!isset($this->providers[$providerId])) {
$this->providers[$providerId] = $provider;
}
}
public function unregister(string $providerId): void
{
unset($this->providers[$providerId]);
}
public function getProvider(string $providerId): ?PaymentProviderInterface
{
return $this->providers[$providerId] ?? null;
}
public function getProviders(): array
{
return $this->providers;
}
public function getEnabledProviders(): array
{
$enabled = [];
foreach ($this->providers as $provider) {
if ($provider->isAvailable()) {
$enabled[$provider->getProviderId()] = $provider;
}
}
return $enabled;
}
public function getEnabledPaymentTypes(): array
{
$paymentTypes = [];
foreach ($this->getEnabledProviders() as $provider) {
$providerTypes = $provider->getPaymentTypes();
foreach ($providerTypes as $key => $label) {
$paymentTypes[$key] = $label;
}
}
return $paymentTypes;
}
public function getProviderForPaymentType(string $paymentTypeKey): ?PaymentProviderInterface
{
foreach ($this->providers as $provider) {
$types = $provider->getPaymentTypes();
if (isset($types[$paymentTypeKey])) {
return $provider;
}
}
return null;
}
public function getProviderByTransactionId(string $transactionId): ?PaymentProviderInterface
{
$transactionModel = model(\App\Models\PaymentTransaction::class);
$transaction = $transactionModel->where('transaction_id', $transactionId)->first();
if (!$transaction) {
return null;
}
return $this->getProvider($transaction['provider_id']);
}
public function hasProviders(): bool
{
return !empty($this->providers);
}
public function count(): int
{
return count($this->providers);
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Libraries\Payments\Providers;
use App\Libraries\Payments\PaymentProviderBase;
class PayPalProvider extends PaymentProviderBase
{
public function getProviderId(): string
{
return 'paypal';
}
public function getProviderName(): string
{
return 'PayPal';
}
public function getProviderDescription(): string
{
return 'Accept payments using PayPal (Zettle) card reader terminals and QR code payments.';
}
public function getVersion(): string
{
return '1.0.0';
}
public function getPaymentTypes(): array
{
return [
'paypal_card' => lang('Sales.paypal_card') ?? 'Card (PayPal/Zettle)',
'paypal_qr' => lang('Sales.paypal_qr') ?? 'PayPal QR',
];
}
public function getIcon(?string $paymentType = null): ?string
{
return base_url('images/payment_providers/paypal.svg');
}
public function initiatePayment(float $amount, string $currency = 'USD', array $options = []): array
{
$orderId = $options['order_id'] ?? uniqid('paypal_', true);
$paymentType = $options['payment_type'] ?? 'paypal_card';
$this->logTransaction(
$orderId,
'pending',
$amount,
$currency,
$options['sale_id'] ?? null,
['payment_type' => $paymentType, 'options' => $options]
);
return [
'success' => true,
'transaction_id' => $orderId,
'status' => 'pending',
'order_id' => $orderId,
'amount' => $amount,
'currency' => $currency,
'payment_type' => $paymentType,
'message' => 'Payment initiated. ' .
($paymentType === 'paypal_qr'
? 'Customer can scan QR code to complete payment.'
: 'Use PayPal Zettle terminal to complete.'),
];
}
public function processCallback(array $data): array
{
$eventType = $data['event_type'] ?? '';
$orderId = $data['resource']['id'] ?? $data['order_id'] ?? null;
if (!$orderId) {
return ['success' => false, 'error' => 'Missing order ID'];
}
$transaction = $this->getTransaction($orderId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED':
case 'PAYMENT.CAPTURE.COMPLETED':
$this->updateTransactionStatus($orderId, 'completed', $data);
$this->fire('completed', [
'transaction_id' => $orderId,
'sale_id' => $transaction['sale_id'],
'amount' => $transaction['amount'],
]);
return ['success' => true, 'status' => 'completed'];
case 'PAYMENT.CAPTURE.DENIED':
case 'PAYMENT.CAPTURE.DECLINED':
$this->updateTransactionStatus($orderId, 'failed', $data);
$this->fire('failed', [
'transaction_id' => $orderId,
'error' => $data['resource']['status_details'] ?? 'Payment declined',
]);
return ['success' => false, 'status' => 'failed', 'error' => 'Payment declined'];
default:
return ['success' => false, 'error' => "Unknown event type: {$eventType}"];
}
}
public function getPaymentStatus(string $transactionId): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => $transaction['status'],
'amount' => (float)$transaction['amount'],
'currency' => $transaction['currency'],
];
}
public function refund(string $transactionId, float $amount, string $reason = ''): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
if ($transaction['status'] !== 'completed') {
return ['success' => false, 'error' => 'Transaction cannot be refunded'];
}
$this->updateTransactionStatus($transactionId, 'refunded', [
'refund_amount' => $amount,
'refund_reason' => $reason,
]);
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => 'refunded',
'refund_amount' => $amount,
];
}
public function cancel(string $transactionId): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
if ($transaction['status'] !== 'pending') {
return ['success' => false, 'error' => 'Transaction cannot be cancelled'];
}
$this->updateTransactionStatus($transactionId, 'cancelled');
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => 'cancelled',
];
}
public function getConfigView(): ?string
{
return 'Payments/paypal_config';
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Libraries\Payments\Providers;
use App\Libraries\Payments\PaymentProviderBase;
class SumUpProvider extends PaymentProviderBase
{
public function getProviderId(): string
{
return 'sumup';
}
public function getProviderName(): string
{
return 'SumUp';
}
public function getProviderDescription(): string
{
return 'Accept card payments using SumUp card reader terminals.';
}
public function getVersion(): string
{
return '1.0.0';
}
public function getPaymentTypes(): array
{
return [
'sumup_card' => lang('Sales.sumup_card') ?? 'Card (SumUp)',
];
}
public function getIcon(?string $paymentType = null): ?string
{
return base_url('images/payment_providers/sumup.svg');
}
public function initiatePayment(float $amount, string $currency = 'USD', array $options = []): array
{
$checkoutReference = $options['checkout_reference'] ?? uniqid('sumup_', true);
$this->logTransaction(
$checkoutReference,
'pending',
$amount,
$currency,
$options['sale_id'] ?? null,
['options' => $options]
);
return [
'success' => true,
'transaction_id' => $checkoutReference,
'status' => 'pending',
'checkout_reference' => $checkoutReference,
'amount' => $amount,
'currency' => $currency,
'message' => 'Payment initiated. Use SumUp terminal to complete.',
];
}
public function processCallback(array $data): array
{
$eventType = $data['event_type'] ?? '';
$checkoutId = $data['checkout_id'] ?? $data['id'] ?? null;
if (!$checkoutId) {
return ['success' => false, 'error' => 'Missing checkout ID'];
}
$transaction = $this->getTransaction($checkoutId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
switch ($eventType) {
case 'payment.success':
$this->updateTransactionStatus($checkoutId, 'completed', $data);
$this->fire('completed', [
'transaction_id' => $checkoutId,
'sale_id' => $transaction['sale_id'],
'amount' => $transaction['amount'],
]);
return ['success' => true, 'status' => 'completed'];
case 'payment.failed':
$this->updateTransactionStatus($checkoutId, 'failed', $data);
$this->fire('failed', [
'transaction_id' => $checkoutId,
'error' => $data['error_message'] ?? 'Unknown error',
]);
return ['success' => false, 'status' => 'failed', 'error' => $data['error_message'] ?? 'Payment failed'];
default:
return ['success' => false, 'error' => "Unknown event type: {$eventType}"];
}
}
public function getPaymentStatus(string $transactionId): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => $transaction['status'],
'amount' => (float)$transaction['amount'],
'currency' => $transaction['currency'],
];
}
public function refund(string $transactionId, float $amount, string $reason = ''): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
if ($transaction['status'] !== 'completed') {
return ['success' => false, 'error' => 'Transaction cannot be refunded'];
}
$this->updateTransactionStatus($transactionId, 'refunded', [
'refund_amount' => $amount,
'refund_reason' => $reason,
]);
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => 'refunded',
'refund_amount' => $amount,
];
}
public function cancel(string $transactionId): array
{
$transaction = $this->getTransaction($transactionId);
if (!$transaction) {
return ['success' => false, 'error' => 'Transaction not found'];
}
if ($transaction['status'] !== 'pending') {
return ['success' => false, 'error' => 'Transaction cannot be cancelled'];
}
$this->updateTransactionStatus($transactionId, 'cancelled');
return [
'success' => true,
'transaction_id' => $transactionId,
'status' => 'cancelled',
];
}
public function getConfigView(): ?string
{
return 'Payments/sumup_config';
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PaymentTransaction extends Model
{
protected $table = 'payment_transactions';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $useSoftDeletes = false;
protected $allowedFields = [
'provider_id',
'sale_id',
'transaction_id',
'amount',
'currency',
'status',
'metadata',
'created_at',
'updated_at'
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_REFUNDED = 'refunded';
public const STATUS_CANCELLED = 'cancelled';
public function getTransaction(string $transactionId, ?string $providerId = null): ?array
{
$builder = $this->builder();
$builder->where('transaction_id', $transactionId);
if ($providerId !== null) {
$builder->where('provider_id', $providerId);
}
$result = $builder->get()->getRowArray();
return $result;
}
public function getTransactionsBySale(int $saleId): array
{
return $this->where('sale_id', $saleId)
->orderBy('created_at', 'DESC')
->findAll();
}
public function getPendingTransactions(?string $providerId = null): array
{
$builder = $this->builder();
$builder->where('status', self::STATUS_PENDING);
if ($providerId !== null) {
$builder->where('provider_id', $providerId);
}
return $builder->get()->getResultArray();
}
public function updateStatus(int $id, string $status, array $additionalData = []): bool
{
$data = ['status' => $status];
if (!empty($additionalData['metadata'])) {
$existing = $this->find($id);
if ($existing) {
$existingMetadata = json_decode($existing['metadata'] ?? '{}', true);
$data['metadata'] = json_encode(array_merge($existingMetadata, $additionalData['metadata']));
}
}
return $this->update($id, $data);
}
}

View File

@@ -465,6 +465,12 @@ helper('url');
<div class="btn btn-sm btn-success pull-right" id="add_payment_button" tabindex="<?= ++$tabindex ?>">
<span class="glyphicon glyphicon-credit-card">&nbsp;</span><?= lang(ucfirst($controller_name) . '.add_payment') ?>
</div>
<?php if (function_exists('payment_provider_content')): ?>
<div id="payment_provider_actions">
<?= payment_provider_content('register_payment_actions', ['amount_due' => $amount_due ?? 0, 'payments' => $payments ?? []]) ?>
</div>
<?php endif; ?>
<?php } ?>
<?php if (count($payments) > 0) { // Only show this part if there is at least one payment entered. ?>