Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2cfb1f1
KNA-3366/feat: Basic tests and general order logic improvements
SalmanTwo Feb 26, 2026
221cadf
KNA-3366/feat: Additional test and improvements
SalmanTwo Feb 26, 2026
d5b1caf
KNA-3366/fix: Explicitly terminate non-AJAX order intent requests
SalmanTwo Feb 26, 2026
28deb06
KNA-3366/feat: Translation coverage and updating Two order details view
SalmanTwo Feb 26, 2026
8c13b46
KNA-3366/feat: Updated Translations
SalmanTwo Feb 26, 2026
705514e
KNA-3366/feat: More robust order intent behaviour
SalmanTwo Mar 3, 2026
51c812f
PDEV-3366/feat: Corrected positioning for country selector in address…
SalmanTwo Mar 9, 2026
8daebe3
PDEV-3366/feat: Optional toggle for tax subtotals
SalmanTwo Mar 9, 2026
00be1ae
PDEV-3366/feat: General improvements
SalmanTwo Mar 10, 2026
2c13ef5
PDEV-3366/feat: Better order intent handling and using natvie presta …
SalmanTwo Mar 10, 2026
fcbc793
PDEV-3366/feat: Better discount handling and general improvements
SalmanTwo Mar 10, 2026
3411640
PDEV-3366/feat: Better order intent activation gate and handling eco-…
SalmanTwo Mar 10, 2026
f094d84
PDEV-3366/feat: harden Two checkout flow (strict parity, callback rec…
SalmanTwo Mar 10, 2026
61c4a13
PDEV-3366f/ix: harden order-intent security and canonicalize discount…
SalmanTwo Mar 10, 2026
ed889d0
PDEV-3366/fix: show Standard vs EOM invoice terms on buyer success sc…
SalmanTwo Mar 10, 2026
25311c3
PDEV-3366/feat:Harden ES tax-rate canonicalization (default 0.21 fall…
SalmanTwo Mar 10, 2026
f7d4d5d
PDEV-3366/feat: harden ES tax-rate snapping and mixed cart-rule disco…
SalmanTwo Mar 11, 2026
5367f2c
PDEV-3366/feat:make buyer cancel terminal, block late verified/fulfil…
SalmanTwo Mar 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Module Tests

on:
push:
pull_request:
workflow_dispatch:

jobs:
test:
name: PHP ${{ matrix.php-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ["8.2", "8.3"]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none

- name: Syntax check test files
run: |
php -l tests/bootstrap.php
php -l tests/OrderBuilderTest.php
php -l tests/run.php

- name: Run offline test suite
run: php tests/run.php
64 changes: 63 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,69 @@ All notable changes to the Two Payment module for PrestaShop will be documented
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## Latest Release: v2.4.0

**Release Date:** 2026-02-25

**Highlights:**
- Cart snapshot guard to block local order creation if cart changes after Two order creation
- Idempotency key header on `/v1/order` creation to prevent duplicate provider orders on retries
- Added attempt metadata columns for snapshot hash and order-create idempotency key

**Upgrade:** Includes database migration creating/updating `twopayment_attempt`.

---

## [2.4.0] - 2026-02-25

### Added
- **Checkout Attempt Persistence**: New `twopayment_attempt` table tracks provider-first checkout attempts
- Stores attempt token, cart/customer linkage, Two order metadata, and lifecycle status
- Supports idempotent callback handling and safe retries
- **Cart Snapshot Consistency Check**: Callback finalization validates cart still matches original checkout payload hash
- If cart drift is detected, local order creation is blocked
- Provider order is cancelled (best effort) and customer is sent back to checkout
- **Order Create Idempotency Header**: `/v1/order` calls include `X-Idempotency-Key`
- Key is derived from cart/customer/environment and normalized snapshot hash
- Reduces duplicate provider orders when requests are retried
- **Attempt Metadata Columns**:
- `cart_snapshot_hash`
- `order_create_idempotency_key`

### Changed
- **Provider-First Checkout Flow**: Payment controller now creates Two orders before local PrestaShop orders
- Eliminates local order creation/deletion cycle on provider rejection
- Prevents rejected attempts from producing local order side effects
- **merchant_order_id Alignment**: After callback-time local order creation, module performs best-effort Two order update to set `merchant_order_id` to the real PrestaShop `id_order`
- **Callback Orchestration**:
- Confirmation controller now supports `attempt_token` callback flow and creates local order only after verified provider state
- Cancel controller now supports `attempt_token` cancellation without creating local orders
- Both controllers keep legacy `id_order` paths for backward compatibility
- **Tax Payload Accuracy Hardening**:
- Tax rates are now serialized with dedicated high precision (no money-format truncation)
- Product tax rate selection now prioritizes applied PrestaShop amounts when configured and applied rates diverge
- Order-level `tax_rate` is derived from final net/tax totals
- **Provider Error Handling Hardening**:
- `getTwoErrorMessage()` now treats HTTP `>= 400` as an error even when provider body is empty/non-JSON
- Nested `data.error_message`/`data.message` responses are now parsed consistently
- **Session Company Country Safety**:
- Legacy company cookies without `two_company_country` are now cleared when validating against a known address country
- Prevents stale cross-country company/org-number reuse in mixed-country checkouts

### Technical
- Added upgrade script `upgrade-2.4.0.php`
- Module version bumped to `2.4.0`
- `twopayment_attempt` schema includes snapshot and idempotency metadata
- Added strict line-item formula validation gate before building intent/create/update payloads
- Added test harness and automated checks:
- Offline deterministic test runner (`php tests/run.php`)
- PHPUnit-compatible test suite scaffolding (`tests/OrderBuilderTest.php`, `phpunit.xml.dist`)
- GitHub Actions workflow for push/PR test execution
- Added coverage for HTTP-only provider failures and legacy session company country edge cases

---
## [2.3.2] - 2026-01-22

### Added
Expand Down Expand Up @@ -258,4 +321,3 @@ None in version 2.2.0 - all changes are backwards compatible.


For detailed technical changes, see git commit history.

11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ Two is a B2B payment method that lets your business customers pay by invoice wit
- Robust tax rate calculation with fallback validation
- User-friendly error messages for API validation failures
- Phone number fallback (phone → phone_mobile)
- Provider-first checkout finalization (local order created after provider verification)
- Cart snapshot validation before callback-time local order creation
- Idempotency key header on provider order creation requests

## Requirements

Expand Down Expand Up @@ -171,10 +174,10 @@ Payment is due at the **end of the current month (at fulfillment) plus X days**.
#### 3. Order Confirmation
- Customer clicks "Place Order" with Two selected
- Module verifies Order Intent server-side (defense-in-depth)
- If valid, PrestaShop order created
- Two order created via API
- Payment data saved to database
- Customer redirected to confirmation page
- Module creates Two order first (provider-first)
- If Two rejects, checkout stops and no PrestaShop order is created
- If Two verifies, module creates PrestaShop order from callback and saves payment data
- Customer is redirected to native PrestaShop order confirmation page

### Order Management

Expand Down
16 changes: 16 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "two/twopayment-prestashop-module",
"description": "Development dependencies for Two PrestaShop module tests",
"type": "project",
"license": "proprietary",
"require": {},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"config": {
"sort-packages": true
},
"scripts": {
"test": "phpunit -c phpunit.xml.dist"
}
}
2 changes: 1 addition & 1 deletion config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<module>
<name>twopayment</name>
<displayName><![CDATA[Two - BNPL for businesses]]></displayName>
<version><![CDATA[2.3.2]]></version>
<version><![CDATA[2.4.0]]></version>
<description><![CDATA[This module allows any merchant to accept payments with Two payment gateway.]]></description>
<author><![CDATA[Two]]></author>
<tab><![CDATA[payments_gateways]]></tab>
Expand Down
186 changes: 148 additions & 38 deletions controllers/front/cancel.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,54 +18,164 @@ public function postProcess()
{
parent::postProcess();

$id_order = Tools::getValue('id_order');
$attempt_token = trim((string)Tools::getValue('attempt_token'));
if (!Tools::isEmpty($attempt_token)) {
$this->handleAttemptCancel($attempt_token);
return;
}

if (isset($id_order) && !Tools::isEmpty($id_order)) {
$order = new Order((int) $id_order);
$id_order = (int)Tools::getValue('id_order');
if ($id_order > 0) {
$this->handleLegacyOrderCancel($id_order);
return;
}

$this->module->restoreDuplicateCart($order->id, $order->id_customer);
// Use custom Two state if available, fallback to mapped state
$cancelled_status = Configuration::get('PS_TWO_OS_CANCELLED');
if (!$cancelled_status) {
$cancelled_status = Configuration::get('PS_TWO_OS_CANCELLED_MAP');
if (!$cancelled_status) {
$cancelled_status = Configuration::get('PS_OS_CANCELED');
}
$message = $this->module->l('Unable to find the requested order please contact store owner.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

private function handleAttemptCancel($attempt_token)
{
$attempt = $this->module->getTwoCheckoutAttempt($attempt_token);
if (!$attempt) {
$message = $this->module->l('Unable to find the requested payment attempt.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

if (!$this->isAuthorizedAttemptCallback($attempt)) {
PrestaShopLogger::addLog(
'TwoPayment: Unauthorized cancel callback for attempt ' . $attempt_token,
3
);
$message = $this->module->l('Unable to validate this cancellation callback. Please retry checkout.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

$attempt_order_id = (int)$attempt['id_order'];
if ($attempt_order_id > 0) {
// Safety: if the attempt is already linked to a local order, reuse legacy cancellation behavior.
$this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'CANCELLED');
$this->handleLegacyOrderCancel($attempt_order_id);
return;
}

$extra_data = array();
if (!empty($attempt['two_order_id'])) {
$two_order_id = $attempt['two_order_id'];
$cancel_response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/cancel', [], 'POST');
$cancel_http_status = isset($cancel_response['http_status']) ? (int)$cancel_response['http_status'] : 0;
if ($cancel_http_status >= Twopayment::HTTP_STATUS_BAD_REQUEST) {
PrestaShopLogger::addLog(
'TwoPayment: Attempt cancel failed for token ' . $attempt_token . ', Two order ' . $two_order_id .
', HTTP ' . $cancel_http_status,
2
);
}
$this->module->changeOrderStatus($order->id, $cancelled_status);

$orderpaymentdata = $this->module->getTwoOrderPaymentData($id_order);
if ($orderpaymentdata && isset($orderpaymentdata['two_order_id'])) {
$two_order_id = $orderpaymentdata['two_order_id'];

$response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/cancel', [], 'POST');
if (!isset($response)) {
$message = sprintf($this->module->l('Could not update status to cancelled, please check with Two admin for id %s'), $two_order_id);
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

$response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET');
if (isset($response['state']) && $response['state'] == 'CANCELLED') {
$payment_data = array(
'two_order_id' => $response['id'],
'two_order_reference' => $response['merchant_reference'],
'two_order_state' => $response['state'],
'two_order_status' => $response['status'],
'two_day_on_invoice' => (string)$this->module->getSelectedPaymentTerm(), // Selected payment term
'two_invoice_url' => $response['invoice_url'],
);
$this->module->setTwoOrderPaymentData($order->id, $payment_data);
$response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET');
$response_http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0;
if ($response_http_status > 0 && $response_http_status < Twopayment::HTTP_STATUS_BAD_REQUEST && is_array($response)) {
$extra_data['two_order_state'] = isset($response['state']) ? $response['state'] : '';
$extra_data['two_order_status'] = isset($response['status']) ? $response['status'] : '';
if (isset($response['invoice_url'])) {
$extra_data['two_invoice_url'] = $response['invoice_url'];
}
if (isset($response['invoice_details']['id'])) {
$extra_data['two_invoice_id'] = $response['invoice_details']['id'];
}
}
$message = $this->module->l('Your order is cancelled.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
} else {
}

$this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'CANCELLED', $extra_data);

// Keep the cart active for retry after cancellation.
$cart = new Cart((int)$attempt['id_cart']);
if (Validate::isLoadedObject($cart)) {
$this->context->cart = $cart;
$this->context->cookie->id_cart = (int)$cart->id;
$this->context->cookie->write();
}

$message = $this->module->l('Your order is cancelled.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

private function handleLegacyOrderCancel($id_order)
{
$order = new Order((int)$id_order);
if (!Validate::isLoadedObject($order)) {
$message = $this->module->l('Unable to find the requested order please contact store owner.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

$this->module->restoreDuplicateCart($order->id, $order->id_customer);
$this->module->changeOrderStatus($order->id, $this->getCancelledStatus());

$orderpaymentdata = $this->module->getTwoOrderPaymentData($id_order);
if ($orderpaymentdata && isset($orderpaymentdata['two_order_id'])) {
$two_order_id = $orderpaymentdata['two_order_id'];

$response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/cancel', [], 'POST');
$cancel_http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0;
if ($cancel_http_status === 0 || $cancel_http_status >= Twopayment::HTTP_STATUS_BAD_REQUEST) {
$message = sprintf($this->module->l('Could not update status to cancelled, please check with Two admin for id %s'), $two_order_id);
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

$response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET');
$response_http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0;
if ($response_http_status > 0 && $response_http_status < Twopayment::HTTP_STATUS_BAD_REQUEST && isset($response['state']) && $response['state'] == 'CANCELLED') {
$payment_data = array(
'two_order_id' => $response['id'],
'two_order_reference' => $response['merchant_reference'],
'two_order_state' => $response['state'],
'two_order_status' => $response['status'],
'two_day_on_invoice' => (string)$this->module->getSelectedPaymentTerm(), // Selected payment term
'two_invoice_url' => $response['invoice_url'],
'two_payment_term_type' => isset($orderpaymentdata['two_payment_term_type']) ? $orderpaymentdata['two_payment_term_type'] : Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'),
);
$this->module->setTwoOrderPaymentData($order->id, $payment_data);
}
}

$message = $this->module->l('Your order is cancelled.');
$this->errors[] = $message;
$this->redirectWithNotifications('index.php?controller=order');
}

private function getCancelledStatus()
{
$cancelled_status = Configuration::get('PS_TWO_OS_CANCELLED');
if (!$cancelled_status) {
$cancelled_status = Configuration::get('PS_TWO_OS_CANCELLED_MAP');
if (!$cancelled_status) {
$cancelled_status = Configuration::get('PS_OS_CANCELED');
}
}
return (int)$cancelled_status;
}

/**
* Validate callback authorization against stored attempt secure key.
*/
private function isAuthorizedAttemptCallback($attempt)
{
$provided_key = trim((string)Tools::getValue('key'));
$context_customer_id = (isset($this->context->customer->id)) ? (int)$this->context->customer->id : 0;
$context_customer_secure_key = (isset($this->context->customer->secure_key)) ? (string)$this->context->customer->secure_key : '';

return $this->module->isTwoAttemptCallbackAuthorized(
$attempt,
$provided_key,
$context_customer_id,
$context_customer_secure_key
);
}

}
Loading