diff --git a/composer.json b/composer.json index 81f2922..f4c4440 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "altapay/craftcms-altapay", - "description": "Altapay gateway integration for Craft Commerce 5", + "description": "AltaPay gateway integration for Craft Commerce 5", "type": "craft-plugin", "version": "5.0.0", "keywords": [ @@ -25,7 +25,7 @@ "authors": [ { "name": "AltaPay A/S", - "homepage": "https://www.altapay.com/" + "homepage": "https://www.altapay.com/" } ], "require": { @@ -39,10 +39,8 @@ } }, "extra": { - "name": "Altapay for Craft Commerce", + "name": "Altapay", "handle": "craftcms-altapay", - "class": "QD\\altapay\\Altapay", - "hasCpSettings": false, - "hasCpSection": false + "class": "QD\\altapay\\Altapay" } } diff --git a/src/Altapay.php b/src/Altapay.php index 5652a8b..ea956a6 100644 --- a/src/Altapay.php +++ b/src/Altapay.php @@ -11,23 +11,35 @@ class Altapay extends Plugin { + // Use + use Routes; + use Events; + + // Settings public static $plugin; public string $schemaVersion = "5.0.0"; public bool $hasCpSettings = true; public bool $hasCpSection = false; - use Routes; - use Events; + // Hooks + const HOOK_SUBSCRIPTION_AGREEMENT = 'beforeSubscriptionAgreement'; + const HOOK_RECURRING_CHARGE = 'beforeRecurringCharge'; + + // Events + const EVENT_RECURRING_CHARGE = 'afterRecurringCharge'; + const EVENT_SUBSCRIPTION_CREATED = 'afterSubscriptionCreated'; + const EVENT_PAYMENT_AUTHORIZATION = 'afterPaymentAuthorization'; + const EVENT_PAYMENT_CAPTURE = 'afterPaymentCapture'; public function init() { parent::init(); Craft::setAlias('@QD/altapay', __DIR__); + self::$plugin = $this; + $this->routes(); $this->events(); - - self::$plugin = $this; } protected function createSettingsModel(): ?Model diff --git a/src/api/Api.php b/src/api/Api.php index f165ee8..818ed19 100644 --- a/src/api/Api.php +++ b/src/api/Api.php @@ -186,11 +186,11 @@ private static function _code($array) private static function _message($array) { - $header = $array['Header']['ErrorMessage'] ?? null; - if ($header) return implode(', ', $header); + $header = $array['Header']['ErrorMessage'] ?? []; + if ($header) return Utils::stringify($header); $merchant = $array['Body']['MerchantErrorMessage'] ?? null; - if ($merchant) return $merchant; + if ($merchant) return Utils::stringify($merchant); return 'Unknown'; } diff --git a/src/api/SubscriptionApi.php b/src/api/SubscriptionApi.php index 70dff63..db99051 100644 --- a/src/api/SubscriptionApi.php +++ b/src/api/SubscriptionApi.php @@ -4,17 +4,18 @@ class SubscriptionApi extends Api { - public static function chargeSubscription() + public function __construct() { - return 'Not implemented'; + parent::__construct(); } - public static function reserveSubscriptionCharge() - { - return 'Not implemented'; - } - public static function createSubscription(array $payload, $type = 'subscription') + + public static function chargeSubscription(array $payload): ApiResponse { - $payload['type'] = $type; - return PaymentApi::createPaymentRequest($payload); + $response = (new Api()) + ->setMethod('chargeSubscription') + ->setPayload($payload) + ->post(); + + return $response; } } diff --git a/src/config/Data.php b/src/config/Data.php index 823b960..3951ade 100644 --- a/src/config/Data.php +++ b/src/config/Data.php @@ -24,4 +24,15 @@ abstract class Data const PAYMENT_REQUEST_TYPE_SUBSCRIPTION = 'subscription'; const PAYMENT_REQUEST_TYPE_SUBSCRIPTION_CHARGE = 'subscriptionAndCharge'; const PAYMENT_REQUEST_TYPE_SUBSCRIPTION_RESERVE = 'subscriptionAndReserve'; + + const AGREEMENT_TYPE_UNSCHEDULED = 'unscheduled'; + const AGREEMENT_TYPE_RECURRING = 'recurring'; + const AGREEMENT_TYPE_INSTALMENT = 'instalment'; + + const AGREEMENT_UNSCHEDULED_INCREMENTAL = 'incremental'; + const AGREEMENT_UNSCHEDULED_RESUBMISSION = 'resubmission'; + const AGREEMENT_UNSCHEDULED_DELAYED_CHARGES = 'delayedCharges'; + const AGREEMENT_UNSCHEDULED_REAUTHORISATION = 'reauthorisation'; + const AGREEMENT_UNSCHEDULED_NO_SHOW = 'noShow'; + const AGREEMENT_UNSCHEDULED_CHARGE = 'charge'; } diff --git a/src/config/Events.php b/src/config/Events.php index 16111d1..4f5142a 100644 --- a/src/config/Events.php +++ b/src/config/Events.php @@ -32,7 +32,7 @@ protected function global(): void Gateways::EVENT_REGISTER_GATEWAY_TYPES, function (RegisterComponentTypesEvent $event) { $event->types[] = PaymentGateway::class; - // $event->types[] = SubscriptionGateway::class; + $event->types[] = SubscriptionGateway::class; } ); diff --git a/src/config/Routes.php b/src/config/Routes.php index f972fff..15374d1 100644 --- a/src/config/Routes.php +++ b/src/config/Routes.php @@ -24,6 +24,9 @@ private function publicRoutes(): void $event->rules['callback/v1/altapay/payment/fail'] = 'craftcms-altapay/payment-callback/fail'; $event->rules['callback/v1/altapay/payment/open'] = 'craftcms-altapay/payment-callback/open'; $event->rules['callback/v1/altapay/payment/notification'] = 'craftcms-altapay/payment-callback/notification'; + + $event->rules['callback/v1/altapay/recurring/ok'] = 'craftcms-altapay/recurring-callback/ok'; + $event->rules['callback/v1/altapay/recurring/fail'] = 'craftcms-altapay/recurring-callback/fail'; }); } diff --git a/src/config/Utils.php b/src/config/Utils.php index 4fc5023..0d3ee86 100644 --- a/src/config/Utils.php +++ b/src/config/Utils.php @@ -4,6 +4,16 @@ class Utils { + public static function stringify($data): string + { + if (is_string($data)) return $data; + if (is_array($data)) return json_encode($data); + if (is_object($data)) return json_encode($data); + + return ''; + } + + public static function objectify($data): object|array|string { if (is_object($data)) { diff --git a/src/controllers/RecurringCallbackController.php b/src/controllers/RecurringCallbackController.php new file mode 100644 index 0000000..c7848a7 --- /dev/null +++ b/src/controllers/RecurringCallbackController.php @@ -0,0 +1,73 @@ + self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, + 'fail' => self::ALLOW_ANONYMOUS_LIVE | self::ALLOW_ANONYMOUS_OFFLINE, + ]; + + + // callback_ok + public function actionOk() + { + try { + $response = $this->_response(); + $this->_validate($response); + CaptureCallbackService::callback(Data::CALLBACK_OK, $response); + } catch (\Throwable $th) { + throw new Exception($th->getMessage(), 1); + } + } + + // callback_fail + public function actionFail() + { + try { + $response = $this->_response(); + $this->_validate($response); + CaptureCallbackService::callback(Data::CALLBACK_FAIL, $response); + } catch (\Throwable $th) { + throw new Exception($th->getMessage(), 1); + } + } + + private function _response() + { + $request = Craft::$app->getRequest()->getBodyParams(); + + $xml = simplexml_load_string($request['xml'], 'SimpleXMLElement', LIBXML_NOCDATA); + if ($xml === false) throw new Exception('Failed to parse XML response', 1); + unset($request['xml']); + + $meta = json_decode(json_encode($xml), true); + unset($meta['@attributes']); + + $response = Utils::objectify($request); + $response->meta = Utils::objectify($meta); + + return $response; + } + + private function _validate($response) + { + if (!$response->data->CaptureAmount) { + throw new Exception('Invalid response: Missing capture arguments', 1); + } + + if (!$response->data->Transactions->Transaction[0]->PaymentId) { + throw new Exception('Invalid response: Missing payment reference', 1); + } + } +} diff --git a/src/domains/gateways/PaymentGateway.php b/src/domains/gateways/PaymentGateway.php index 966294a..0298c6d 100644 --- a/src/domains/gateways/PaymentGateway.php +++ b/src/domains/gateways/PaymentGateway.php @@ -40,7 +40,7 @@ class PaymentGateway extends Gateway public static function displayName(): string { - return Craft::t('commerce', 'Altapay Payment'); + return Craft::t('commerce', 'AltaPay Payment'); } //* Authorize diff --git a/src/domains/gateways/SubscriptionGateway.php b/src/domains/gateways/SubscriptionGateway.php index 2fee579..a353b45 100644 --- a/src/domains/gateways/SubscriptionGateway.php +++ b/src/domains/gateways/SubscriptionGateway.php @@ -5,249 +5,87 @@ use Craft; use craft\commerce\base\Gateway; use craft\commerce\base\RequestResponseInterface; -use craft\commerce\elements\Order; use craft\commerce\errors\NotImplementedException; use craft\commerce\models\payments\BasePaymentForm; -use craft\commerce\models\payments\OffsitePaymentForm; -use craft\commerce\models\PaymentSource; -use craft\commerce\models\responses\Manual as ManualRequestResponse; use craft\commerce\models\Transaction; -use craft\helpers\App; -use craft\web\Response as WebResponse; - +use QD\altapay\api\PaymentApi; +use QD\altapay\config\Data; +use QD\altapay\domains\payment\AuthorizeService; class SubscriptionGateway extends Gateway { - /** - * @var bool - */ - private string|bool $_onlyAllowForZeroPriceOrders = false; - - public function getSettings(): array - { - $settings = parent::getSettings(); - $settings['onlyAllowForZeroPriceOrders'] = $this->getOnlyAllowForZeroPriceOrders(false); + const SUPPORTS = [ + 'Authorize' => true, + 'Capture' => false, + 'CompleteAuthorize' => false, + 'CompletePurchase' => false, + 'PaymentSources' => false, + 'Purchase' => true, + 'Refund' => false, + 'PartialRefund' => true, + 'Void' => true, + 'Webhooks' => false, + ]; + + use GatewayTrait; + + //* Settings + public string $agreementName = ''; + public string $agreementDescription = ''; + + public string $statusToCapture = Data::NULL_STRING; + public string $statusAfterCapture = Data::NULL_STRING; + public string $terminal = ''; - return $settings; - } + private string|bool $_onlyAllowForZeroPriceOrders = false; - /** - * Returns the display name of this class. - * - * @return string The display name of this class. - */ public static function displayName(): string { - return Craft::t('commerce', 'Altapay Subscription'); - } - - /** - * @inheritdoc - */ - public function getPaymentFormHtml(array $params): ?string - { - return ''; - } - - /** - * @inheritdoc - */ - public function getPaymentFormModel(): BasePaymentForm - { - return new OffsitePaymentForm(); - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - return Craft::$app->getView()->renderTemplate('commerce/gateways/manualGatewaySettings', ['gateway' => $this]); + return Craft::t('commerce', 'AltaPay Subscription'); } - /** - * @inheritdoc - */ + //* Authorize public function authorize(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface { - return new ManualRequestResponse(); + $response = AuthorizeService::execute($transaction); + return $response; } - /** - * @inheritdoc - */ + //* Capture public function capture(Transaction $transaction, string $reference): RequestResponseInterface { - return new ManualRequestResponse(); - } - - /** - * @inheritdoc - */ - public function completeAuthorize(Transaction $transaction): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function completePurchase(Transaction $transaction): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function createPaymentSource(BasePaymentForm $sourceData, int $customerId): PaymentSource - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); + throw new NotImplementedException('Should be handled programatically by calling RecurringService::charge()'); } - /** - * @inheritdoc - */ - public function deletePaymentSource(string $token): bool - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function getPaymentTypeOptions(): array - { - return [ - 'authorize' => Craft::t('commerce', 'Authorize Only (Manually Capture)'), - ]; - } - - /** - * @inheritdoc - */ - public function purchase(Transaction $transaction, BasePaymentForm $form): RequestResponseInterface - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ - public function processWebHook(): WebResponse - { - throw new NotImplementedException(Craft::t('commerce', 'This gateway does not support that functionality.')); - } - - /** - * @inheritdoc - */ + //* Refund public function refund(Transaction $transaction): RequestResponseInterface { - return new ManualRequestResponse(); - } - - /** - * @inheritdoc - */ - public function supportsAuthorize(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCapture(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsCompleteAuthorize(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsCompletePurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsPaymentSources(): bool - { - return false; + throw new NotImplementedException('Currently not supported, refund through dashboard'); } - /** - * @inheritdoc - */ - public function supportsPurchase(): bool - { - return false; - } - - /** - * @inheritdoc - */ - public function supportsRefund(): bool - { - return true; - } - - /** - * @inheritdoc - */ - public function supportsPartialRefund(): bool + //* Settings + public function getSettings(): array { - return true; - } + $settings = parent::getSettings(); + $settings['onlyAllowForZeroPriceOrders'] = $this->getOnlyAllowForZeroPriceOrders(false); - /** - * @inheritdoc - */ - public function supportsWebhooks(): bool - { - return false; + return $settings; } - /** - * @inheritdoc - */ - public function availableForUseWithOrder(Order $order): bool + public function getSettingsHtml(): ?string { - if ($this->getOnlyAllowForZeroPriceOrders() && $order->getTotalPrice() != 0) { - return false; + //* Terminal + $terminals[] = ['value' => Data::NULL_STRING, 'label' => 'None']; + foreach (PaymentApi::getTerminals() as $terminal) { + $terminals[] = ['value' => $terminal->Title, 'label' => $terminal->Title]; } - return parent::availableForUseWithOrder($order); - } + //TODO: Add GUI for editing agreement settings - /** - * @param bool $parse - * @return bool|string - * @since 4.1.1 - */ - public function getOnlyAllowForZeroPriceOrders(bool $parse = true): bool|string - { - return $parse ? App::parseBooleanEnv($this->_onlyAllowForZeroPriceOrders) : $this->_onlyAllowForZeroPriceOrders; - } + $options = [ + 'terminals' => $terminals, + ]; - /** - * @param bool|string $onlyAllowForZeroPriceOrders - * @return void - * @since 4.1.1 - */ - public function setOnlyAllowForZeroPriceOrders(bool|string $onlyAllowForZeroPriceOrders): void - { - $this->_onlyAllowForZeroPriceOrders = $onlyAllowForZeroPriceOrders; + return Craft::$app->getView()->renderTemplate('craftcms-altapay/gateways/subscription', ['gateway' => $this, 'options' => $options]); } } diff --git a/src/domains/payment/AuthorizeService.php b/src/domains/payment/AuthorizeService.php index 6beb50f..7473dab 100644 --- a/src/domains/payment/AuthorizeService.php +++ b/src/domains/payment/AuthorizeService.php @@ -11,7 +11,11 @@ use craft\db\Query; use Throwable; use craft\commerce\db\Table; +use QD\altapay\Altapay; use QD\altapay\config\Data; +use QD\altapay\domains\gateways\SubscriptionGateway; +use QD\altapay\hooks\SubscriptionAgreementHook; +use QD\altapay\services\OrderService; class AuthorizeService { @@ -44,15 +48,14 @@ public static function execute(Transaction $transaction): PaymentResponse 'language' => explode('-', $site->language)[0], // optional + 'type' => Data::PAYMENT_REQUEST_TYPE_PAYMENT, 'transaction_info' => [ 'store' => $order->storeId ?? '', 'order' => $order->id ?? '', + 'number' => $order->number ?? '', 'transaction' => $transaction->hash ?? '', ], - - // General - 'type' => Data::PAYMENT_REQUEST_TYPE_PAYMENT, - // 'sale_reconciliation_identifier' => '', + 'sale_reconciliation_identifier' => $order->number ?? '', // 'credit_card_token' => '', 'fraud_service' => 'none', // 'cookie' => '', @@ -66,13 +69,14 @@ public static function execute(Transaction $transaction): PaymentResponse $payload['customer_info'] = self::_customer($order); $payload['config'] = self::_config($site->baseUrl); - $payload['orderLines'] = self::_lines($order); + $payload['orderLines'] = OrderService::lines($order); + self::_subscription($payload, $gateway); $response = PaymentApi::createPaymentRequest($payload); return new PaymentResponse($response); } - //* Payload + //* PRIVATE private static function _customer(Order $order): array { $customer = $order->getCustomer(); @@ -128,88 +132,6 @@ private static function _customer(Order $order): array return $info; } - private static function _lines(Order $order): array - { - $lines = []; - - // Items - $items = $order->getLineItems(); - if (!$items) return $lines; - - foreach ($items as $item) { - $purchasable = $item->getPurchasable(); - if (!$purchasable) continue; - - //TODO: Allow setting the field in altapay settings - $image = $purchasable->image ?? $purchasable->images ?? $purchasable->variantImages ?? null; - - $unitPrice = $item->taxIncluded ? $item->price - $item->taxIncluded : $item->price; - $taxAmount = $item->taxIncluded ? $item->taxIncluded : 0.00; - - $lines[] = [ - 'description' => $item->description ?: '', - 'itemId' => $purchasable->sku ?: '', - 'quantity' => $item->qty ?: 1, - - 'unitPrice' => Utils::amount($unitPrice), - 'taxAmount' => Utils::amount($taxAmount), - 'discount' => Utils::amount(self::_discount($item->subtotal, $item->total)), - 'goodsType' => 'item', // TODO: Handle giftvouchers? - 'imageUrl' => $image ? Craft::$app->getAssets()->getAssetUrl($image->eagerly()->one()) : '', - 'productUrl' => $purchasable->url ?: '', - ]; - } - - // Adjustments - $adjustments = $order->getAdjustments(); - foreach ($adjustments as $adjustment) { - if ($adjustment->lineItemId) continue; - if ($adjustment->type === 'shipping') continue; - if ($adjustment->type === 'tax') continue; - - switch ($adjustment->type) { - case 'discount': - $type = 'discount'; - break; - default: - $type = 'item'; - break; - } - - $lines[] = [ - 'description' => $adjustment->name ?: 'Adjustment', - 'itemId' => strtolower(str_replace(' ', '-', $adjustment->name ?: 'adjustment')), - 'quantity' => 1, - - 'unitPrice' => Utils::amount($adjustment->amount), - 'taxAmount' => Utils::amount(0.00), - 'discount' => 0.00, - 'goodsType' => $type, - 'imageUrl' => '', - 'productUrl' => '', - ]; - } - - // Shipping - $shipping = $order->totalShippingCost ?: null; - if ($shipping) { - $lines[] = [ - 'description' => $order->shippingMethodName ?: 'Shipping', - 'itemId' => $order->shippingMethodHandle ?: 'shipping', - 'quantity' => 1, - - 'unitPrice' => Utils::amount($shipping), - 'taxAmount' => Utils::amount(0.00), - 'discount' => 0.00, - 'goodsType' => 'shipment', - 'imageUrl' => '', - 'productUrl' => '', - ]; - } - - return $lines; - } - private static function _config($url): array { return [ @@ -220,12 +142,10 @@ private static function _config($url): array ]; } - //* PRIVATE private static function _reference(Order &$order): Order { if ($order->reference) return $order; - $referenceTemplate = $order->getStore()->getOrderReferenceFormat(); try { $baseReference = Craft::$app->getView()->renderObjectTemplate($referenceTemplate, $order); @@ -261,13 +181,43 @@ private static function _reference(Order &$order): Order return $order; } - private static function _discount($subtotal, $total): float + private static function _subscription(&$payload, $gateway) { - if ($subtotal <= 0) return 0; - if ($subtotal === $total) return 0; - if ($total <= 0) return 100; - - $discount = (($subtotal - $total) / $subtotal) * 100; - return (float) $discount ?? 0.00; + // Use default payment settings if order is not a subscription + if (!$gateway instanceof SubscriptionGateway) return; + + // Set subscription data + $payload['type'] = Data::PAYMENT_REQUEST_TYPE_SUBSCRIPTION; + + // HOOK + //? This hook allows other developers to modify the subscription agreement data + $plugin = Altapay::getInstance(); + $hook = new SubscriptionAgreementHook([ + 'payload' => $payload, + 'gateway' => $gateway, + 'agreement' => [ + 'type' => Data::AGREEMENT_TYPE_UNSCHEDULED, + ], + ]); + + $plugin->trigger(Altapay::HOOK_SUBSCRIPTION_AGREEMENT, $hook); + $payload['agreement'] = $hook->agreement; + + // Handle unscheduled + //? In case the subscription is unscheduled, this method is only used for authorization + //? so we leave amount and items empty, as they will be a part of the individual charges + $isUnscheduled = ($hook->agreement['type'] ?? '') === Data::AGREEMENT_TYPE_UNSCHEDULED; + if ($isUnscheduled) { + $payload['amount'] = Utils::amount(0); + $payload['orderLines'] = [ + [ + 'goodsType' => 'subscription_model', + 'itemId' => $gateway->agreementName ?? 'Subscription', + 'description' => $gateway->agreementDescription ?? 'Service', + 'quantity' => 1, + 'unitPrice' => Utils::amount(1), + ] + ]; + } } } diff --git a/src/domains/payment/CaptureCallbackService.php b/src/domains/payment/CaptureCallbackService.php new file mode 100644 index 0000000..ccf19ff --- /dev/null +++ b/src/domains/payment/CaptureCallbackService.php @@ -0,0 +1,61 @@ +data->Transactions->Transaction[0] ?? null; + if (!$action) throw new Exception("Invalid response: Missing transaction data", 1); + + $parent = TransactionService::getTransactionByReference($action->PaymentId); + if (!$parent) throw new Exception("Transaction not found", 1); + + $order = $parent->getOrder(); + if (!$order) throw new Exception("Order not found", 1); + + $status = self::_status($response->data->Result); + $child = TransactionService::create($order, $parent, $parent->reference, RecordsTransaction::TYPE_CAPTURE, $status, $response); + + // EVENT + $plugin = Altapay::getInstance(); + if ($plugin->hasEventHandlers(Altapay::EVENT_RECURRING_CHARGE)) { + $event = new RecurringChargeEvent([ + 'order' => $order, + 'transaction' => $child, + 'status' => $status + ]); + $plugin->trigger(Altapay::EVENT_RECURRING_CHARGE, $event); + } + } + + private static function _status(string $result): string + { + switch ($result) { + case Data::RESPONSE_SUCCESS: + return RecordsTransaction::STATUS_SUCCESS; + + case Data::RESPONSE_ERROR: + case Data::RESPONSE_FAIL: + return RecordsTransaction::STATUS_FAILED; + + case Data::RESPONSE_OPEN: + return RecordsTransaction::STATUS_PROCESSING; + + case Data::RESPONSE_PARTIAL_SUCCESS: + throw new Exception("Partial Success not implemented", 1); + + default: + throw new Exception("Unknown response status: $result", 1); + } + } +} diff --git a/src/domains/payment/CaptureService.php b/src/domains/payment/CaptureService.php index 796629b..0e6a249 100644 --- a/src/domains/payment/CaptureService.php +++ b/src/domains/payment/CaptureService.php @@ -5,6 +5,7 @@ use Exception; use QD\altapay\api\PaymentApi; use QD\altapay\config\Utils; +use QD\altapay\services\OrderService; use QD\altapay\services\TransactionService; class CaptureService @@ -30,8 +31,8 @@ public static function execute(string $reference) $payload = [ 'amount' => Utils::amount($amount), - // 'orderLines' = [], - // 'reconciliation_identifier' => '', + 'orderLines' => OrderService::lines($order), + 'reconciliation_identifier' => $order->number ?? '', // 'sales_tax' => 0, 'transaction_id' => $reference ]; diff --git a/src/domains/subscription/RecurringService.php b/src/domains/subscription/RecurringService.php new file mode 100644 index 0000000..24f8771 --- /dev/null +++ b/src/domains/subscription/RecurringService.php @@ -0,0 +1,106 @@ +id, RecordsTransaction::TYPE_CAPTURE, RecordsTransaction::STATUS_PENDING, $id); + if (!$parent) $parent = TransactionService::create($order, null, $id, RecordsTransaction::TYPE_CAPTURE, RecordsTransaction::STATUS_PENDING); + + $site = Craft::$app->getSites()->getSiteById($order->siteId); + if (!$site) throw new Exception("Site not found", 1); + + // PAYLOAD + $payload = [ + 'amount' => Utils::amount($order->total), + 'reconciliation_identifier' => $order->number ?? '', + 'transaction_info' => [ + 'store' => $order->storeId ?? '', + 'order' => $order->id ?? '', + 'transaction' => $transaction->hash ?? '', + 'subscription' => $id, + ], + 'agreement' => [ + 'id' => $id, + ], + 'config' => [ + 'callback_ok' => $site->baseUrl . 'callback/v1/altapay/recurring/ok', + 'callback_fail' => $site->baseUrl . 'callback/v1/altapay/recurring/fail', + ], + 'orderLines' => OrderService::lines($order), + ]; + + // HOOK + $plugin = Altapay::getInstance(); + $hook = new RecurringChargeHook([ + 'order' => $order, + + 'unscheduled_type' => Data::AGREEMENT_UNSCHEDULED_INCREMENTAL, + 'retry_days' => null, + 'surcharge_amount' => null, + 'dynamic_descriptor' => null, + ]); + $plugin->trigger(Altapay::HOOK_RECURRING_CHARGE, $hook); + + if ($hook->unscheduled_type) $payload['agreement']['unscheduled_type'] = $hook->unscheduled_type; + if ($hook->retry_days) $payload['agreement']['retry_days'] = $hook->retry_days; + if ($hook->surcharge_amount) $payload['surcharge_amount'] = $hook->surcharge_amount; + if ($hook->dynamic_descriptor) $payload['dynamic_descriptor'] = $hook->dynamic_descriptor; + + $response = SubscriptionApi::chargeSubscription($payload); + $status = self::_status($response->data->Result); + $child = TransactionService::create($order, $parent, $response->data->Transactions->Transaction[0]->PaymentId, RecordsTransaction::TYPE_CAPTURE, $status, $response); + + // EVENT + if ($plugin->hasEventHandlers(Altapay::EVENT_RECURRING_CHARGE)) { + $event = new RecurringChargeEvent([ + 'order' => $order, + 'transaction' => $child, + 'status' => $status + ]); + $plugin->trigger(Altapay::EVENT_RECURRING_CHARGE, $event); + } + + return $response; + } + + private static function _status(string $result): string + { + switch ($result) { + case Data::RESPONSE_SUCCESS: + return RecordsTransaction::STATUS_SUCCESS; + + case Data::RESPONSE_ERROR: + case Data::RESPONSE_FAIL: + return RecordsTransaction::STATUS_FAILED; + + case Data::RESPONSE_OPEN: + return RecordsTransaction::STATUS_PROCESSING; + + case Data::RESPONSE_PARTIAL_SUCCESS: + throw new Exception("Partial Success not implemented", 1); + + default: + throw new Exception("Unknown response status: $result", 1); + } + } +} diff --git a/src/domains/subscription/SubscriptionService.php b/src/domains/subscription/SubscriptionService.php deleted file mode 100644 index b9b8bd3..0000000 --- a/src/domains/subscription/SubscriptionService.php +++ /dev/null @@ -1,18 +0,0 @@ -getGateway(); if (!$gateway) throw new Exception("Gateway not found", 1); - if (!$gateway->statusAfterCapture === Data::NULL_STRING) return; + if ($gateway->statusAfterCapture === Data::NULL_STRING) return; $status = Commerce::getInstance()->getOrderStatuses()->getOrderStatusByHandle($gateway->statusAfterCapture, $order->storeId); if (!$status) throw new Exception("Order status not found", 1); @@ -21,4 +23,98 @@ public static function setAfterCaptureStatus($order) $order->orderStatusId = $status->id; Craft::$app->getElements()->saveElement($order); } + + + // Data + public static function lines(Order $order): array + { + $lines = []; + + // Items + $items = $order->getLineItems(); + if (!$items) return $lines; + + foreach ($items as $item) { + $purchasable = $item->getPurchasable(); + if (!$purchasable) continue; + + //TODO: Allow setting the field in altapay settings + $image = $purchasable->image ?? $purchasable->images ?? $purchasable->variantImages ?? null; + + $unitPrice = $item->taxIncluded ? $item->price - $item->taxIncluded : $item->price; + $taxAmount = $item->taxIncluded ? $item->taxIncluded : 0.00; + + $lines[] = [ + 'description' => $item->description ?: '', + 'itemId' => $purchasable->sku ?: '', + 'quantity' => $item->qty ?: 1, + + 'unitPrice' => Utils::amount($unitPrice), + 'taxAmount' => Utils::amount($taxAmount), + 'discount' => Utils::amount(self::discount($item->subtotal, $item->total)), + 'goodsType' => 'item', // TODO: Handle verbb/git-vouchers? + 'imageUrl' => $image ? Craft::$app->getAssets()->getAssetUrl($image->eagerly()->one()) : '', + 'productUrl' => $purchasable->url ?: '', + ]; + } + + // Adjustments + $adjustments = $order->getAdjustments(); + foreach ($adjustments as $adjustment) { + if ($adjustment->lineItemId) continue; + if ($adjustment->type === 'shipping') continue; + if ($adjustment->type === 'tax') continue; + + switch ($adjustment->type) { + case 'discount': + $type = 'discount'; + break; + default: + $type = 'item'; + break; + } + + $lines[] = [ + 'description' => $adjustment->name ?: 'Adjustment', + 'itemId' => strtolower(str_replace(' ', '-', $adjustment->name ?: 'adjustment')), + 'quantity' => 1, + + 'unitPrice' => Utils::amount($adjustment->amount), + 'taxAmount' => Utils::amount(0.00), + 'discount' => 0.00, + 'goodsType' => $type, + 'imageUrl' => '', + 'productUrl' => '', + ]; + } + + // Shipping + $shipping = $order->totalShippingCost ?: null; + if ($shipping) { + $lines[] = [ + 'description' => $order->shippingMethodName ?: 'Shipping', + 'itemId' => $order->shippingMethodHandle ?: 'shipping', + 'quantity' => 1, + + 'unitPrice' => Utils::amount($shipping), + 'taxAmount' => Utils::amount(0.00), + 'discount' => 0.00, + 'goodsType' => 'shipment', + 'imageUrl' => '', + 'productUrl' => '', + ]; + } + + return $lines; + } + + public static function discount($subtotal, $total): float + { + if ($subtotal <= 0) return 0; + if ($subtotal === $total) return 0; + if ($total <= 0) return 100; + + $discount = (($subtotal - $total) / $subtotal) * 100; + return (float) $discount ?? 0.00; + } } diff --git a/src/services/TransactionService.php b/src/services/TransactionService.php index 49a665c..13320a6 100644 --- a/src/services/TransactionService.php +++ b/src/services/TransactionService.php @@ -7,6 +7,11 @@ use craft\commerce\Plugin as Commerce; use Exception; use craft\commerce\records\Transaction as RecordsTransaction; +use QD\altapay\Altapay; +use QD\altapay\config\Data; +use QD\altapay\events\PaymentAuthorizationEvent; +use QD\altapay\events\PaymentCaptureEvent; +use QD\altapay\events\SubscriptionCreatedEvent; class TransactionService { @@ -31,21 +36,80 @@ public static function authorize(string $status, object $response, string $msg = throw new Exception("Transaction could not be saved: " . json_encode($transaction->getErrors()), 1); } + // EVENT + $plugin = Altapay::getInstance(); + switch ($response->type) { + case Data::PAYMENT_REQUEST_TYPE_PAYMENT: + $plugin = Altapay::getInstance(); + $event = new PaymentAuthorizationEvent([ + 'order' => $order, + 'transaction' => $transaction, + 'status' => $status + ]); + + if ($plugin->hasEventHandlers(Altapay::EVENT_PAYMENT_AUTHORIZATION)) { + $plugin->trigger(Altapay::EVENT_PAYMENT_AUTHORIZATION, $event); + } + break; + + case Data::PAYMENT_REQUEST_TYPE_SUBSCRIPTION: + $event = new SubscriptionCreatedEvent([ + 'order' => $order, + 'transaction' => $transaction, + 'status' => $status, + 'id' => $response->transaction_id + ]); + + if ($plugin->hasEventHandlers(Altapay::EVENT_SUBSCRIPTION_CREATED)) { + $plugin->trigger(Altapay::EVENT_SUBSCRIPTION_CREATED, $event); + } + break; + } + return $transaction; } - public static function capture(string $status, object $response, string $msg = '', string $code = ''): Transaction + public static function captureTransaction(Transaction $transaction): Transaction { - $parent = self::getTransactionByReference($response->payment_id); - if (!$parent) throw new Exception("Parent transaction not found", 1); + $order = $transaction->getOrder(); + if (!$order) throw new Exception("Order not found for transaction", 1); - $order = $parent->getOrder(); - if (!$order) throw new Exception("Order not found", 1); + $child = Commerce::getInstance()->getPayments()->captureTransaction($transaction); + switch ($child->status) { + case RecordsTransaction::STATUS_SUCCESS: + $order->updateOrderPaidInformation(); + break; + case RecordsTransaction::STATUS_PROCESSING: + break; + case RecordsTransaction::STATUS_PENDING: + break; + default: + throw new Exception('Could not capture payment'); + break; + } + + // EVENT + $plugin = Altapay::getInstance(); + $event = new PaymentCaptureEvent([ + 'order' => $order, + 'transaction' => $transaction, + 'status' => $child->status + ]); + + if ($plugin->hasEventHandlers(Altapay::EVENT_PAYMENT_CAPTURE)) { + $plugin->trigger(Altapay::EVENT_PAYMENT_CAPTURE, $event); + } + + return $child; + } + + public static function create(Order $order, ?Transaction $parent = null, string|int $reference, string $type, string $status, mixed $response = null, ?string $msg = '', ?string $code = '') + { $transaction = Commerce::getInstance()->getTransactions()->createTransaction($order, $parent); - $transaction->type = RecordsTransaction::TYPE_CAPTURE; + $transaction->type = $type; $transaction->status = $status; - $transaction->reference = $response->payment_id; + $transaction->reference = $reference; $transaction->response = $response; $transaction->message = $msg; $transaction->code = $code; @@ -55,6 +119,10 @@ public static function capture(string $status, object $response, string $msg = ' throw new Exception("Transaction could not be saved: " . json_encode($transaction->getErrors()), 1); } + if ($transaction->type === RecordsTransaction::TYPE_CAPTURE && $transaction->status === RecordsTransaction::STATUS_SUCCESS) { + $order->updateOrderPaidInformation(); + } + return $transaction; } @@ -104,26 +172,25 @@ public static function getSuccessfulTransaction(int $orderId): ?Transaction return $validTransactions[0]; } - public static function captureTransaction(Transaction $transaction): Transaction + public static function getLatestParentByConfig(int $orderId, $type, $status, $reference): ?Transaction { - $order = $transaction->getOrder(); - if (!$order) throw new Exception("Order not found for transaction", 1); + $transactions = Commerce::getInstance()->getTransactions()->getAllTransactionsByOrderId($orderId); - $child = Commerce::getInstance()->getPayments()->captureTransaction($transaction); + $validTransactions = array_filter($transactions, function ($transaction) use ($type, $status, $reference) { + return $transaction->type === $type && $transaction->status === $status && $transaction->reference === $reference && !$transaction->parentId; + }); - switch ($child->status) { - case RecordsTransaction::STATUS_SUCCESS: - $order->updateOrderPaidInformation(); - break; - case RecordsTransaction::STATUS_PROCESSING: - break; - case RecordsTransaction::STATUS_PENDING: - break; - default: - throw new Exception('Could not capture payment'); - break; + // If no transactions found, return null + if (empty($validTransactions)) { + return null; } - return $child; + // Sort by dateCreated in descending order (newest first) + usort($validTransactions, function ($a, $b) { + return $b->dateCreated <=> $a->dateCreated; + }); + + // Return the first (newest) transaction + return $validTransactions[0]; } } diff --git a/src/templates/gateways/settings/agreement.twig b/src/templates/gateways/settings/agreement.twig new file mode 100644 index 0000000..3b4ecfb --- /dev/null +++ b/src/templates/gateways/settings/agreement.twig @@ -0,0 +1,27 @@ +{% from "_includes/forms" import textField %} +