diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d18936e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +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 twopayment.php + php -l controllers/front/payment.php + 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b8da9c1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md — Two Payment Module (PrestaShop) + +Project-specific instructions for AI coding agents working in this repository. + +## Scope + +These rules apply to all files under this module directory. + +## Mission + +Build and maintain a robust B2B payment module where Two provider behavior and PrestaShop order state stay consistent, auditable, and safe. + +## Hard Constraints + +1. Never create a local PrestaShop order if Two rejects/fails order creation. +2. Apply rejection/rollback protections globally (not by country-specific exception). +3. Preserve provider-first flow and retry idempotency. +4. Do not weaken server-side validation in favor of frontend checks. +5. Keep tax/amount formulas consistent with existing test expectations. +6. Never expose secrets in logs or code. +7. Never default to insecure transport behavior. + +## Required Verification Before Claiming Done + +Run from module root: + +```bash +php -l twopayment.php +php tests/run.php +``` + +If you edited additional PHP files, lint each one: + +```bash +php -l path/to/file.php +``` + +## i18n Requirements + +For every user-facing string change: +- Update PHP/Smarty translation surfaces (`$this->l`, `{l ...}`) +- Update JS i18n dictionary in `twopayment.php` when used by frontend modules +- Update `translations/es.php` with natural Spanish +- Avoid hardcoded English UI fallback where module i18n is available + +## File Ownership Reference + +- `twopayment.php`: hooks, settings, API interactions, payload and i18n map +- `controllers/front/payment.php`: checkout confirmation + order creation safety +- `controllers/front/orderintent.php`: order intent API and gating data +- `views/js/modules/*.js`: checkout UX logic and client validation +- `views/templates/hook/*.tpl`: admin and checkout rendering +- `tests/OrderBuilderTest.php`: tax/amount/order payload invariants + +## Change Quality Rules + +- Keep diffs targeted; avoid unrelated refactors in payment-critical paths. +- Preserve backward compatibility unless change request is explicit. +- Add or update tests for behavior changes in payload, validation, or flow control. +- Update `CHANGELOG.md` for functional changes. + +## Release Consistency Rules + +When bumping/releasing versions, keep these in sync: +- `twopayment.php` version +- `config.xml` version +- `CHANGELOG.md` + +## Common Failure Patterns to Avoid + +- Reintroducing local order writes before provider success. +- Losing idempotency on retries/timeouts. +- Country-specific tax/error branching that bypasses global safeguards. +- Admin UI showing invoice actions too early in order lifecycle. +- Updating JS messages without adding corresponding translation keys. diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md new file mode 100644 index 0000000..5bb29fa --- /dev/null +++ b/AI_CONTEXT.md @@ -0,0 +1,162 @@ +# Two Payment Plugin for PrestaShop + +AI agent operating manual for the `twopayment` module. + +## 1) Current Truth + +| Item | Value | +| --- | --- | +| Module | `twopayment` | +| Version | `2.4.0` | +| PrestaShop support | `1.7.6` to `9.x` | +| Core model | Provider-first checkout (Two first, PrestaShop order second) | +| Main file | `twopayment.php` | + +Canonical version sources: +- `twopayment.php` (`$this->version`) +- `config.xml` (``) +- `CHANGELOG.md` top entry + +These must stay aligned. + +## 2) Product Goal + +Reliable B2B invoice checkout via Two, with: +- No phantom local orders when provider-side creation/review fails +- Accurate tax/amount payload parity with PrestaShop totals +- Safe retry behavior (idempotency) +- Clear admin observability of Two order lifecycle + +## 3) Non-Negotiable Invariants + +1. Never create a local PrestaShop order if Two order creation/verification fails. +2. Apply the rejection/rollback rule for all countries, not Spain-only logic. +3. Keep provider-first flow intact: Two acceptance/verification gates local order creation. +4. Keep idempotency on provider order creation paths. +5. Do not weaken server-side validation gates even if frontend checks exist. +6. Preserve tax math integrity (`tax_amount = net_amount * tax_rate`, within rounding constraints). +7. Do not expose secrets or disable SSL verification by default. +8. If user-facing text changes, update all required i18n surfaces (PHP, JS i18n map, translations). + +## 4) Core Flow (High Level) + +### Checkout and Order Creation +1. Buyer enters/selects company details. +2. Order intent check runs (frontend + server-side persistence/validation). +3. Two order is created first. +4. Only after provider-side success, local PrestaShop order is finalized. + +### Retry/Idempotency +- Repeated submit/callback paths must not duplicate provider orders. +- Attempt tracking table + idempotency headers are part of the safety model. + +### Admin Order View +- Show stable stored identifiers and state info. +- Where available, refresh/fetch current provider metadata for accuracy. +- Invoice links/actions should only be shown when lifecycle state permits (for example after fulfillment). + +## 5) File Ownership Map + +### Core Module +- `twopayment.php` + - Hooks, config fields, API wrappers, payload building, i18n JS dictionary + - Main place for business rules and invariants + +### Front Controllers +- `controllers/front/payment.php` + - Provider-first payment orchestration and final local order safety +- `controllers/front/orderintent.php` + - Ajax order intent, company context, guardrails +- `controllers/front/confirmation.php` + - Post-checkout result handling +- `controllers/front/cancel.php` + - Cancellation/error paths + +### Frontend Checkout Modules +- `views/js/modules/TwoCheckoutManager.js` + - Payment-option behavior, approval/decline UX, terms UI +- `views/js/modules/TwoOrderIntent.js` + - Order intent polling and UI messaging +- `views/js/modules/TwoCompanySearch.js` + - Company discovery and selection +- `views/js/modules/TwoFieldValidation.js` + - Account/company field validation behavior + +### Admin UI +- `views/templates/hook/displayAdminOrderLeft.tpl` +- `views/templates/hook/displayAdminOrderTabContent.tpl` + +### Tests +- `tests/OrderBuilderTest.php` +- `tests/run.php` + +### Upgrades +- `upgrade/upgrade-*.php` + +## 6) i18n Rules + +When adding/changing user-facing strings: +1. Wrap PHP strings with `$this->l(...)`. +2. For JS, expose keys via `Media::addJsDef(... 'i18n' => [...])` in `twopayment.php`. +3. Consume keys in JS; avoid raw English literals for user-facing errors/messages. +4. Add/update `translations/es.php` with natural Spanish, not literal machine phrasing. +5. Re-check template `{l s=... mod='twopayment'}` coverage. + +## 7) Tax and Amount Safety + +Before changing payload builders: +- Verify line-item formulas and rounding behavior. +- Ensure product-level, shipping, and discount lines reconcile to order totals. +- Validate behavior for mixed rates and tax-exempt contexts. +- Keep fallback tax-derivation logic deterministic. + +Always run the order-builder test suite after tax/amount edits. + +## 8) Verification Commands (Minimum) + +Run from module root: + +```bash +php -l twopayment.php +php -l controllers/front/payment.php +php -l controllers/front/orderintent.php +php tests/run.php +``` + +For broader edits, lint any touched PHP files and re-run tests. + +## 9) Logging and Debugging + +- Use `PrestaShopLogger::addLog` with actionable context (`order_id`, `two_order_id`, endpoint/action). +- Never log API keys, tokens, or sensitive customer data. +- Use debug mode only for targeted diagnosis; keep normal logs concise. + +## 10) Common Regression Risks + +1. Reintroducing local order writes before provider acceptance. +2. Country-specific error branching that bypasses global rejection guardrails. +3. Broken idempotency on retries/timeouts. +4. Silent mismatch between frontend i18n keys and PHP-provided dictionary. +5. Admin view showing invoice actions before provider invoice lifecycle is ready. +6. Version mismatch across `twopayment.php`, `config.xml`, `CHANGELOG.md`. + +## 11) Release Hygiene Checklist + +Before release/tag: +1. Version synchronized across module files. +2. Upgrade script exists and is referenced where needed. +3. Changelog entry accurate and merged cleanly. +4. Tests green. +5. No temporary debug code/messages. +6. Translation updates included for changed user-facing text. + +## 12) Working Style for Agents + +- Prefer minimal, targeted diffs over broad rewrites. +- Preserve backward compatibility unless explicitly changing behavior. +- Document any behavior-changing decision in `CHANGELOG.md` and relevant docs. +- If a rule here conflicts with code behavior, update this file in the same change. + +## Revision Notes + +- 2026-02-26: Rewritten for v2.4.0 architecture and provider-first safeguards. diff --git a/CHANGELOG.md b/CHANGELOG.md index 006070f..c18b566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,216 @@ 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 +- **Security hardening for callback and template surfaces**: + - Legacy `id_order` confirmation/cancel front-controller paths now require secure callback authorization (query `key` or matching logged-in customer secure key) before any order-state mutation. + - Buyer/admin/payment-return templates now escape dynamic Two/order fields before rendering links and text. + - Production environment now enforces TLS verification even if the optional SSL-disable flag is set. +- **Order intent and callback hardening (provider-first parity)**: + - Authoritative payment-submit order intent now fail-closes on strict reconciliation drift before provider `/v1/order_intent` call. + - Callback-time local order creation now wraps `validateOrder()` with race-safe recovery using existing order-by-cart lookup. + - Provider lifecycle cleanup now performs best-effort cancel on terminal post-create failures (including missing `payment_url`) with explicit lifecycle logs. +- **Order intent i18n normalization**: + - Replaced remaining hardcoded order-intent user-facing errors with translation-surface strings. + - Added Spanish (`es`) translations for the normalized order-intent error keys. +- **Coverage and validation documentation updates**: + - Added test coverage for strict payment-submit drift blocking, callback race recovery, and provider cancel helper behavior. + - Added real-engine integration matrix requirements for PrestaShop `1.7.8`, `8.x`, and `9.x` under `tests/integration/README.md`. +- **Order intent/company-search client auth safety + intent address parity**: + - Added client-side request guards on frontend public Two API calls (`/v1/order_intent`, company search/detail endpoints) to block accidental auth header propagation. + - Order intent payload now includes both `billing_address` and `shipping_address` for parity with order create/update payload composition. +- **Discount tax-rate canonicalization hardening**: + - Discount-line fallback tax-rate derivation now snaps near-context drift to canonical cart tax contexts (for example `0.212` -> `0.21`), preventing provider-side strict VAT rate rejections on ES orders. +- **Shipping tax-rate canonicalization hardening**: + - Shipping-line tax rate now snaps to canonical cart/carrier tax contexts when drift is only rounding noise (for example `0.211` -> `0.21`), preventing provider-side strict ES VAT rejections. +- **Additional tax-rate drift hardening (wrapping + product fallback)**: + - Gift-wrapping tax-rate derivation now snaps to canonical cart tax contexts when drift is rounding-only. + - Product-line fallback tax-rate derivation now reuses configured product tax-rate contexts to avoid minor synthetic drift when a line is missing/loses its direct rate field. +- **ES strict fallback default for unresolved line rates**: + - Added an ES-only canonical normalization pass across built line items. + - When a line tax-rate remains unresolved but formula-safe with canonical fallback, the fallback defaults to `0.21`. +- **Buyer confirmation payment-term clarity**: + - Post-order buyer success card now renders invoice terms with explicit term type: `Standard + X days` or `End of Month + X days`. +- **Tax precision hardening for payload formulas**: + - Line-item `tax_rate` serialization now preserves non-integer VAT rates (for example `0.055` for 5.5%) to keep `tax_amount = net_amount * tax_rate` consistent. + - Tax subtotal grouping precision remains compatibility-safe while checkout snapshot tax-rate normalization remains stable at two decimals. +- **Cart-rule-aligned discount attribution**: + - Discount line generation now prefers PrestaShop cart-rule monetary fields (`value_real`, `value_tax_exc`) to keep per-rule discount lines aligned with invoice semantics. + - Weighted tax-context allocation remains as fallback when rule-level monetary metadata is unavailable. + - Mixed cart-rule metadata handling now preserves complete rule rows and falls back only for unresolved remainder, with unresolved free-shipping remainder carved out on shipping VAT context. +- **Currency compatibility guardrails**: + - Added explicit cart-currency compatibility checks in `hookPaymentOptions()` following PrestaShop payment-module patterns. + - Added server-side currency guard in payment submit controller to fail fast before provider calls when currency is unsupported. + - Added explicit ISO allowlist coverage in module checks for `NOK`, `GBP`, `SEK`, `USD`, `DKK`, and `EUR` (all fully supported). +- **Checkout address-basis consistency**: + - Order intent backend now prioritizes invoice/billing address identity and keeps delivery only as fallback for backward compatibility. + - Frontend order intent payload now sends both invoice and delivery address identifiers to keep mixed-theme flows compatible. +- **Idempotency and callback safety**: + - Order-create idempotency key no longer depends on a time bucket for identical cart snapshots. + - Added callback-time rebinding guard to prevent overwriting an existing local order binding with a different Two order ID. +- **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 +- **Unified Checkout Company Resolver**: + - Payment controller now uses shared module fallback logic for company/org-number extraction + - Applies country-aware cookie validation and multi-field org-number extraction consistently at checkout +- **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 +- **Two cancellation/verification consistency hardening**: + - Buyer portal URL resolution now uses explicit buyer domains by environment (`buyer.two.inc` for production and `buyer.sandbox.two.inc` for non-production), with a safe sandbox fallback for unknown environments. + - Checkout callback handling now treats canceled attempts as terminal during confirmation, and cancel flow resolves local order linkage via cart fallback to avoid race-driven state mismatches between Two (`CANCELLED`) and PrestaShop. + - Local order-state sync now force-maps provider `CANCELLED` to the configured PrestaShop cancellation status during confirmation handling and admin provider-sync refresh. + - Legacy cancel callback no longer sets local cancelled state unless provider order fetch confirms `CANCELLED`, preventing transient local cancel entries when provider cancellation did not complete. + - Fulfillment status updates now block/revert when the provider order is `CANCELLED` (using stored and fresh provider state checks), with explicit logs to prevent shipping progression on non-fulfillable Two orders. + - Back-office fulfillment blocking now also surfaces an on-screen warning in the admin controller when a cancelled Two order is reverted to cancelled status. + - Added `actionObjectOrderHistoryAddBefore` guard to rewrite pending `Verified` and fulfillment-trigger history inserts to the configured cancelled status when the provider order or attempt is terminally `CANCELLED`, preventing visible status flip-flops in order history. + - Late confirmation race handling now blocks post-cancel status rewrites (`CONFIRMED`/`FAILED`) so a buyer-backed-out checkout remains cancelled. +- **Tax Payload Accuracy Hardening**: + - Tax rates are now serialized to fixed 2 decimal places (`tax_rate` like `0.21`) across line items, tax subtotals, and checkout snapshots + - Product tax rate selection now prioritizes applied PrestaShop amounts when configured and applied rates diverge + - Top-level `tax_rate` is omitted from `/v1/order` and `/v1/order_intent` request payloads + - `tax_subtotals` is optional and omitted entirely when `PS_TWO_ENABLE_TAX_SUBTOTALS` is disabled + - Added back-office setting `PS_TWO_ENABLE_TAX_SUBTOTALS` in "Other Settings" to control whether `tax_subtotals` is sent +- **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 +- **Business Account Gate Strictness**: + - When account-type mode is enabled, checkout now requires explicit `account_type=business` for Two visibility and order-intent approval. + - Missing `account_type` no longer auto-falls back to company/org-number inference in strict mode. +- **Order Intent Enforcement**: + - Removed the admin toggle for order intent pre-approval from "Other Settings" + - Enforced order intent as mandatory for Two checkout server-side validation + - Updated checkout initialization to always run order intent pre-check logic +- **Checkout Compatibility Hardening**: + - Reworked `CustomerAddressFormatter` override to delegate to core formatter and apply only minimal Two-specific field adjustments + - Removed remote CDN jQuery fallback from front-controller media hook + - Added same-origin runtime jQuery fallback loader in frontend module bootstrap for legacy environments +- **Address Switching Reliability**: + - Prevented stale same-country session company reuse when the shopper switches to a different checkout address/company + - Added address-aware session marker (`two_company_address_id`) for company-cookie synchronization + - Reset order-intent UI/server state and re-enable Two payment option after checkout address updates + - Cleared stale hidden `companyid` values when company input changes to avoid cross-address mismatch blocking +- **Checkout Step Stability**: + - Restricted order-intent submit interception to payment confirmation forms/buttons only (no blocking on personal-info or address step continue actions) + - Removed fallback Two-selection detection based on generic form action matching to avoid false positives outside payment step +- **Organization Number Parsing**: + - VAT extraction now strips prefix only when it matches the current address country ISO (prevents truncating valid org numbers like `SC806781` for GB) +- **Order Intent Company Context**: + - Bound checkout approval message company name to backend order-intent payload company data + - Cleared stale `lastCompany` state on order-intent reset to prevent cross-address message leakage +- **Address Selector Accuracy**: + - Order-intent and company-cookie flows now read the selected (`:checked`) checkout address ID instead of the first address input in DOM + - Order-intent server resolver now uses selected delivery/invoice address context consistently for country/company resolution +- **Two Payload Parity Hardening (Phase 1)**: + - Intent/create/update payloads now share one server-side line-item builder and bottom-up amount derivation + - Shipping is represented as explicit `SHIPPING_FEE` line and cart discounts as explicit negative line items + - Added fail-closed order/cart reconciliation gate before outbound order payloads when totals drift beyond tolerance +- **Order Intent Auth Boundary**: + - Added endpoint-aware header policy so `/v1/order_intent` never includes `X-API-Key` + - Server-to-server Two endpoints keep API-key authentication on backend requests +- **Payment Submit Authorization Hardening**: + - `/payment` now performs a fresh backend `/v1/order_intent` check and treats frontend intent cookies as telemetry only + - Checkout submit token validation is enforced before provider calls in payment submission +- **Callback Amount Integrity**: + - Callback-time `validateOrder()` now uses provider `gross_amount` from Two order response + - Local order creation is blocked when provider amount is missing/invalid + +### Fixed +- **Gift wrapping parity**: + - Added explicit gift wrapping line-item construction so wrapping totals are represented in Two payloads and reconcile with PrestaShop grand totals. +- **Order intent payload regression on rounded mixed discounts**: + - Discount line-item tax rate now uses higher precision when derived from rounded net/tax splits to preserve `tax_amount = net_amount * tax_rate` validation in large cart-rule discount scenarios (including free-shipping combinations). +- **Cart-rule discount VAT context compliance**: + - Cart-rule discount rows now split into canonical tax-rate segments when needed, avoiding blended synthetic VAT rates while preserving per-rule net/gross totals. + - Improves provider compatibility on strict VAT validation paths for mixed discount baskets. +- **Fallback free-shipping attribution hardening**: + - When cart-rule monetary metadata is incomplete, fallback discount logic now attributes free-shipping discounts to the shipping VAT context first. + - Reduces blended shipping/product discount attribution drift on mixed-tax baskets in fallback mode. +- **Order intent account-type strict enforcement**: + - In account-type mode, order intent now blocks missing/non-business account types instead of treating missing values as business. +- **Ecotax explicit line modeling**: + - Product lines now split ecotax into a dedicated `SERVICE` line when safe ecotax totals are present, preserving formula integrity and explicit tax context. +- **Payment term cookie warnings in tests/runtime**: + - Guarded cookie reads in `getSelectedPaymentTerm()` to avoid undefined property warnings. +- **Buyer metadata warning suppression**: + - `buyer_department` and `buyer_project` payload fields are now read with property checks to avoid undefined property warnings on default address entities. +- **Checkout Address Formatter Stability**: + - Fixed `CustomerAddressFormatter` override constructor to call `parent::__construct(...)` + - Prevents `Call to a member function trans() on null` fatals on `/order` during checkout address step rendering + - Preserves Two-specific field adjustments while keeping core formatter translator initialization intact +- **Checkout Address Field Order**: + - Restored country selector positioning immediately before company field in checkout addresses + - Keeps core field metadata/validation intact by reordering existing formatter output instead of rebuilding fields +- **Address Identification Number Guard**: + - Added frontend guard on checkout address submit to prevent backend failures when country requires identification number and `dni` is empty + - Auto-fills `dni` from `companyid`/`vat_number` when available before submit +- **Checkout Country Switch (UK → ES) 500 Regression**: + - Fixed `CustomerAddressFormatter` override `setCountry()/getCountry()` to delegate to core formatter state + - Prevents stale country format when shopper switches address country during checkout (e.g. UK to Spain) + - Ensures ES-required `dni` validation is applied before persistence, avoiding `Property Address->dni is empty` fatals +- **Order Intent Reconciliation False Negatives**: + - Increased order/cart reconciliation tolerance to `0.02` to match real PrestaShop cent-level rounding drift + - Reconciliation drift is now warning-level by default and does not block order-intent precheck payloads + - Reconciliation threshold checks now compare integer cents to avoid float precision boundary rejects at exactly `0.02` +- **Provider-First Reconciliation Handling**: + - Intent payload builder continues when cart reconciliation drift is detected + - Create/update payload builders only hard-block on material mismatches (> `1.00`) to guard true parity errors + - Module logs drift details for observability while avoiding local false-negative blocks from cent-level artifacts +- **Presta-Native Amount Modeling**: + - Product and shipping line monetary fields now keep PrestaShop net/tax/gross totals as canonical values + - Discount totals are split across detected tax contexts instead of a single blended synthetic discount line + - Preserves line-level formula compliance while better matching PrestaShop rounding behavior +- **Discount Rule Description Warning**: + - Guarded optional `value` key access in cart-rule description builder to avoid PHP warnings on stores where cart-rule payload omits that key + +### 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 back-office media hook implementation for module/admin order styling consistency +- Fixed settings persistence path: `PS_TWO_DISABLE_SSL_VERIFY` now saves through "Other Settings" handler (where field is rendered) +- 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 + - CI syntax checks now include core module/controller files in addition to test files + - Added coverage for HTTP-only provider failures, legacy session company country edge cases, shared checkout company resolver behavior, admin media hook routing, account-type fallback gating, and SSL setting persistence paths + +--- ## [2.3.2] - 2026-01-22 ### Added @@ -258,4 +468,3 @@ None in version 2.2.0 - all changes are backwards compatible. For detailed technical changes, see git commit history. - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ef4aa42..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,509 +0,0 @@ -# Two Payment Plugin for PrestaShop - -> **AI Agent Context File** - Primary source of truth for AI development assistance. -> This file should be read at the start of every session and updated when learnings occur. - ---- - -## Self-Improvement Protocol - -### When to Update This File - -AI agents SHOULD update this file when: -- A bug is fixed that reveals a pattern worth documenting -- A new architectural decision is made -- An existing rule is found to be incorrect or incomplete -- A common mistake keeps recurring -- Cross-version compatibility issues are discovered - -### How to Update - -1. **Add learnings** to the appropriate section below -2. **Correct errors** directly - don't leave wrong information -3. **Add new patterns** with code examples -4. **Update version info** when module version changes -5. **Timestamp significant updates** in the Revision History section - -### Update Format - -When adding learnings, use this format: -```markdown -### [Category] Brief Title -**Problem**: What went wrong -**Solution**: How to fix it -**Code**: Example if applicable -``` - ---- - -## Module Overview - -| Aspect | Value | -|--------|-------| -| Module | `twopayment` v2.3.2 | -| Platform | PrestaShop 1.7.6 - 9.x | -| Type | B2B Payment Gateway (Buy Now Pay Later) | -| Main File | `twopayment.php` (~3900 lines) | -| API | Two Payment API v1 | -| Grade | **Production Payment Module** | - -### File Structure - -``` -twopayment/ -├── twopayment.php # Main module (hooks, config, API) -├── config.xml # Module metadata (version here too!) -├── CLAUDE.md # This file - AI context -├── controllers/front/ -│ ├── orderintent.php # AJAX: Order Intent validation -│ ├── payment.php # Payment processing -│ ├── confirmation.php # Order confirmation -│ └── cancel.php # Order cancellation -├── views/js/modules/ -│ ├── TwoCheckoutManager.js # Checkout orchestration (1600+ lines) -│ ├── TwoOrderIntent.js # Order Intent client-side -│ ├── TwoCompanySearch.js # Company autocomplete -│ └── TwoFieldValidation.js # Form validation -├── classes/ -│ └── TwoInvoiceUploadService.php # Invoice upload service -├── views/templates/hook/ -│ └── paymentinfo.tpl # Payment UI template -└── upgrade/ # Version migration scripts -``` - ---- - -## Critical Rules - -### 1. Payment Module Safety (NON-NEGOTIABLE) - -``` -⛔ NEVER deploy untested payment code to production -⛔ NEVER log sensitive data (API keys, tokens, full card numbers) -⛔ NEVER trust client-side validation alone -⛔ NEVER expose API keys to frontend JavaScript -✅ ALWAYS use try-catch around payment operations -✅ ALWAYS validate server-side before processing -✅ ALWAYS test on multiple PrestaShop versions -``` - -### 2. Cross-Version Compatibility - -**PrestaShop Version Differences:** - -| Version | jQuery | Asset Registration | Key Notes | -|---------|--------|-------------------|-----------| -| 1.7.6-1.7.8 | Theme-dependent, may not load | `registerJavascript()` | jQuery often missing! | -| 8.x | Core managed | `registerJavascript()` | Symfony-based | -| 9.x | Core managed | `registerJavascript()` | Latest Symfony | - -**ALWAYS implement triple-layer jQuery fallback:** - -```php -// In hookActionFrontControllerSetMedia() - -// Layer 1: PrestaShop's native method -if (method_exists($this->context->controller, 'addJquery')) { - $this->context->controller->addJquery(); -} - -// Layer 2: jQuery UI (includes jQuery) -if (method_exists($this->context->controller, 'addJqueryUI')) { - $this->context->controller->addJqueryUI(['ui.core']); -} - -// Layer 3: CDN fallback (GUARANTEED) -$this->context->controller->addJS('https://code.jquery.com/jquery-3.6.0.min.js', false); -``` - -**JavaScript-side safety wrapper (REQUIRED for all JS initialization):** - -```javascript -function waitForJQuery(callback, maxAttempts = 50) { - if (typeof jQuery !== 'undefined' && typeof $ !== 'undefined') { - callback(); - } else if (maxAttempts > 0) { - setTimeout(() => waitForJQuery(callback, maxAttempts - 1), 100); - } else { - console.error('TwoPayment: jQuery not available after timeout'); - } -} - -// Wrap ALL initialization code -waitForJQuery(function() { - $(document).ready(function() { - // Your code here - }); -}); -``` - -### 3. Asset Loading (CRITICAL) - -**Only load on checkout pages - NEVER on all pages:** - -```php -public function hookActionFrontControllerSetMedia() -{ - $controller_name = Tools::getValue('controller'); - $is_checkout_page = in_array($controller_name, ['order', 'orderopc']) || - (isset($this->context->controller->php_self) && - in_array($this->context->controller->php_self, ['order', 'order-opc'])); - - $is_module_page = (isset($this->context->controller) && - $this->context->controller instanceof ModuleFrontController && - $this->context->controller->module->name === $this->name); - - if (!$is_checkout_page && !$is_module_page) { - return; // Don't load assets - breaks cart/product pages! - } - - // Register assets with explicit order (NO async!) - $this->context->controller->registerJavascript( - 'two-module-name', - 'modules/twopayment/views/js/modules/File.js', - ['priority' => 201, 'async' => false] // async:false is critical! - ); -} -``` - -### 4. AJAX Controller Pattern - -```php -class TwopaymentOrderintentModuleFrontController extends ModuleFrontController -{ - public $ajax = true; // CRITICAL: Must be set for AJAX - - // Method name MUST be ajaxProcess + action parameter value - public function ajaxProcessCheckOrderIntent() - { - // Validate token - if (Tools::getValue('token') !== Tools::getToken(false)) { - $this->ajaxDie(json_encode(['error' => 'Invalid token'])); - } - - try { - // Your logic here - $this->ajaxDie(json_encode($response)); - } catch (Exception $e) { - PrestaShopLogger::addLog('TwoPayment: ' . $e->getMessage(), 3); - $this->ajaxDie(json_encode(['error' => 'An error occurred'])); - } - } -} -``` - -**URL Construction (use ? for first param, not &):** - -```php -$url = $this->context->link->getModuleLink($this->name, 'orderintent'); -$url .= '?ajax=1&action=checkOrderIntent&token=' . Tools::getToken(false); -// WRONG: $url .= '&ajax=1'; // Breaks if no existing params! -``` - -### 5. Theme & DOM Compatibility - -**Use multiple selector strategies (themes vary widely):** - -```javascript -findTwoPaymentOption() { - // Strategy 1: Data attribute (most reliable) - let option = document.querySelector('[data-module-name="twopayment"]'); - if (option) return option; - - // Strategy 2: Input value - option = document.querySelector('input[value*="twopayment"]'); - if (option) return option; - - // Strategy 3: Form action - const forms = document.querySelectorAll('form[action*="twopayment"]'); - if (forms.length > 0) return forms[0].closest('.payment-option'); - - // Strategy 4: Content search (last resort) - const labels = document.querySelectorAll('.payment-option label'); - for (let label of labels) { - if (label.textContent.includes('Two')) { - return label.closest('.payment-option'); - } - } - return null; -} -``` - -### 6. Hook Safety Pattern - -```php -public function hookAnyHook($params) -{ - try { - // Your hook logic here - } catch (Exception $e) { - PrestaShopLogger::addLog( - 'TwoPayment: Hook error - ' . $e->getMessage(), - 3, // Error level - null, - 'Module', - $this->id - ); - return; // Don't break the page! - } -} -``` - -### 7. Order Intent Anti-Duplication - -```javascript -triggerOrderIntent() { - // Guard 1: Cooldown (800ms minimum between calls) - const now = Date.now(); - if (now - this._lastIntentRunAt < 800) { - return; - } - this._lastIntentRunAt = now; - - // Guard 2: Result caching - if (this.orderIntent && this.orderIntent.lastResult) { - this.handleOrderIntentResult(this.orderIntent.lastResult); - return; - } - - // Guard 3: Already processing - if (this.orderIntent && this.orderIntent.isProcessing) { - return; - } - - // Execute - this.orderIntent.checkOrderIntent().then(result => { - this.handleOrderIntentResult(result); - }); -} -``` - ---- - -## Common Issues & Solutions - -### Shipping Missing When Free Shipping Cart Rule Active - -**Problem**: Order intent gross_amount is less than checkout total by exactly the shipping cost. - -**Root Cause**: `$cart->getOrderTotal(true, Cart::ONLY_SHIPPING)` returns **0** when a "Free shipping" cart rule is active. - -**Solution**: Use `getPackageShippingCost()` to get carrier cost BEFORE cart rules: - -```php -// CORRECT: Gets actual carrier cost regardless of free shipping rules -$shipping_cost = $cart->getPackageShippingCost((int)$cart->id_carrier, true); - -// WRONG: Returns 0 when free shipping cart rule is active -$shipping_cost = $cart->getOrderTotal(true, Cart::ONLY_SHIPPING); -``` - -### Tax Rate Best Practice - -**Problem**: Tax rate calculated from amounts can have rounding errors (shows 20% instead of 21%). - -**Solution**: Use PrestaShop's native `rate` field as primary source, fall back to calculation: - -```php -// PRIMARY: Use PrestaShop's configured rate (it's the canonical source) -$rate_from_field = isset($line_item['rate']) ? (float)$line_item['rate'] : 0; -$tax_rate = $rate_from_field / 100; // Convert percentage to decimal - -// FALLBACK: Calculate from amounts only when rate field is 0 but tax was charged -if ($rate_from_field == 0 && $gross_amount > $net_amount) { - $tax_rate = ($gross_amount - $net_amount) / $net_amount; -} -``` - -### Shipping Tax Rate Best Practice - -**Problem**: Shipping tax rate derived from amounts instead of carrier configuration. - -**Solution**: Use carrier's tax rules group: - -```php -$carrier_tax_rules_group_id = $carrier->getIdTaxRulesGroup(); -if ($carrier_tax_rules_group_id > 0) { - $tax_manager = TaxManagerFactory::getManager($delivery_address, $carrier_tax_rules_group_id); - $tax_calculator = $tax_manager->getTaxCalculator(); - $shipping_tax_rate_percent = $tax_calculator->getTotalRate(); -} -``` - -### Phone Validation Failures - -**Problem**: "Invalid phone number" from Two API. - -**Solution**: Fallback to mobile: - -```php -$phone = $address->phone; -if (empty($phone)) { - $phone = $address->phone_mobile; -} -``` - -### Gross Amount Mismatch - -**Problem**: "Total invoice amount doesn't match" error. - -**Solution**: Use PrestaShop's rounding: - -```php -$gross_amount = Tools::ps_round($calculated_gross, 2); -// Tolerance: const GROSS_AMOUNT_TOLERANCE = 0.02; -``` - -### Two API Tax Formula Compliance (CRITICAL) - -**Problem**: "Line item tax amount differs from tax rate * net amount" API error. - -**Root Cause**: Two API strictly validates: `tax_amount == net_amount * tax_rate`. PrestaShop may calculate a tax_amount that doesn't exactly match this formula due to rounding at different stages. - -**Solution**: CALCULATE tax_amount from the formula instead of using PrestaShop's value: - -```php -// CORRECT: Calculate tax_amount to guarantee formula compliance -$tax_amount = round($net_amount * $tax_rate, 2); -$gross_amount = $net_amount + $tax_amount; - -// WRONG: Use PrestaShop's pre-calculated tax_amount (may not match formula) -$tax_amount = $line_item['total_wt'] - $line_item['total']; // Don't do this! -``` - -**Key insight**: The tax_rate and net_amount define the tax_amount. Never take tax_amount from one source and tax_rate from another - they must be mathematically consistent. - -### jQuery Not Defined - -**Problem**: `$ is not defined` or `jQuery is not defined`. - -**Solution**: Triple-layer fallback (see section 2) + waitForJQuery wrapper. - -### Store Shows 0% Tax Despite Tax Rule Assigned - -**Problem**: Products have 0% tax even though a tax rule (e.g., "ES Standard rate 21%") is assigned. - -**Root Cause**: This is a **store configuration issue**, not a module bug. The tax rule exists but isn't configured to apply to the relevant country. - -**Solution**: Store admin must edit the tax rule in **International > Taxes > Tax Rules** and ensure the country (e.g., Spain) is added with the correct rate. - ---- - -## API Reference - -### Endpoints - -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `/v1/merchant/verify_api_key` | POST | Validate credentials | -| `/v1/order/intent` | POST | Pre-check buyer eligibility | -| `/v1/order` | POST | Create order | -| `/v1/order/{id}` | GET | Get order details | -| `/v1/order/{id}/fulfillments` | POST | Mark as shipped | -| `/v1/order/{id}/refund` | POST | Issue refund | -| `/companies/v2/company` | GET | Company search | - -### Environments - -| Environment | Base URL | -|-------------|----------| -| Sandbox | `https://sandbox.api.two.inc` | -| Production | `https://api.two.inc` | - -### Order States - -``` -CREATED → CONFIRMED → FULFILLED → (REFUNDED) - ↓ - CANCELLED -``` - ---- - -## Configuration Keys - -| Key | Type | Description | -|-----|------|-------------| -| `PS_TWO_MERCHANT_SHORT_NAME` | string | Merchant identifier | -| `PS_TWO_MERCHANT_API_KEY` | string | API key (NEVER expose!) | -| `PS_TWO_ENVIRONMENT` | enum | `development` or `production` | -| `PS_TWO_ENABLE_ORDER_INTENT` | bool | Enable pre-purchase check | -| `PS_TWO_PAYMENT_TERM_TYPE` | enum | `STANDARD` or `EOM` | -| `PS_TWO_USE_OWN_INVOICES` | bool | Upload own invoices | - ---- - -## Version Bump Checklist - -When updating version: - -1. `twopayment.php` line ~51: `$this->version = 'X.Y.Z';` -2. `config.xml`: `` -3. Create `upgrade/upgrade-X.Y.Z.php` if schema changes -4. Update `CHANGELOG.md` -5. Update version in this file's overview table - ---- - -## Testing Checklist - -Before any release: - -- [ ] PrestaShop 1.7.8 (oldest supported) -- [ ] PrestaShop 8.x (current stable) -- [ ] PrestaShop 9.x (latest) -- [ ] Classic theme + one custom theme -- [ ] Order Intent approval and rejection flows -- [ ] Order fulfillment triggers Two API -- [ ] Order refund triggers Two API -- [ ] No JavaScript console errors -- [ ] No PHP errors in logs -- [ ] Assets only load on checkout pages -- [ ] Mobile responsive checkout - ---- - -## Debugging - -### Enable Debug Mode - -Module Config → Other Settings → Enable Debug Mode → Yes - -### Log Location - -```bash -tail -f /path/to/prestashop/var/logs/*.log | grep TwoPayment -``` - -### Log Levels - -- 1 = Info -- 2 = Warning -- 3 = Error -- 4 = Major/Critical - ---- - -## Revision History - -| Date | Change | By | -|------|--------|-----| -| 2026-01-22 | v2.3.2: Re-enabled invoice upload feature (disabled by default, merchants customize their own invoice templates) | AI | -| 2026-01-22 | v2.3.1: Tax formula compliance fix, Plugin Information admin tab, shipping/free shipping fix | AI | -| 2026-01-22 | Fixed shipping detection with free shipping cart rules; improved tax rate calculation to use native PrestaShop fields | AI | -| 2026-01-22 | Created comprehensive CLAUDE.md with self-improvement protocol | AI | -| 2025-11-21 | v2.3.0: EOM payment terms, debug mode | Dev | -| 2025-11-14 | v2.2.0: Invoice upload, SSL verification | Dev | -| 2025-10-06 | v2.1.2: jQuery triple-layer fallback | Dev | - ---- - -## AI Instructions Summary - -1. **Read this file first** at the start of any session -2. **Follow the patterns** documented here exactly -3. **Update this file** when you learn something new -4. **Test across versions** - never assume 1.7.x works like 9.x -5. **Wrap in try-catch** - never break checkout -6. **Log appropriately** - but never sensitive data -7. **Use defensive programming** - multiple fallbacks always diff --git a/README.md b/README.md index 9196999..3308196 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -325,11 +328,40 @@ twopayment/ - **TwoOrderIntent**: Order Intent validation (client-side) - **TwoCompanySearch**: Company search functionality +## Developer & AI Quickstart + +### Start Here + +- [AI_CONTEXT.md](AI_CONTEXT.md): AI operating manual (architecture, invariants, pitfalls) +- [AGENTS.md](AGENTS.md): repository guardrails for any coding agent +- [tests/README.md](tests/README.md): test coverage and execution details +- [CHANGELOG.md](CHANGELOG.md): behavior/history reference + +Compatibility note: +- [CLAUDE.md](CLAUDE.md) is a pointer to `AI_CONTEXT.md` for tooling compatibility only. + +### Mandatory Invariants + +- Never create a local PrestaShop order if Two rejects order creation or verification. +- Keep provider-first checkout flow and retry idempotency intact. +- Apply rejection safeguards globally (not country-specific). +- Keep tax and amount formulas aligned with PrestaShop totals and test expectations. +- Update translation surfaces (`$this->l`, JS i18n map, `translations/es.php`) for user-facing text changes. + +### Minimum Verification Before Commit + +```bash +php -l twopayment.php +php tests/run.php +``` + +If you modified more PHP files, lint each touched file as well. + ## API Integration ### Endpoints Used - `/v1/merchant/verify_api_key` - API key validation -- `/v1/order/intent` - Order Intent check +- `/v1/order_intent` - Order Intent check - `/v1/order` - Order creation - `/v1/order/{id}` - Order updates, refunds - `/v1/invoice/{id}/upload` - Invoice upload initiation @@ -476,4 +508,4 @@ Two Commercial License ## Copyright -© 2021-2026 Two Team \ No newline at end of file +© 2021-2026 Two Team diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a704f7f --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/config.xml b/config.xml index 937264e..ebeb63c 100644 --- a/config.xml +++ b/config.xml @@ -2,7 +2,7 @@ twopayment - + diff --git a/controllers/front/cancel.php b/controllers/front/cancel.php index 0c03bf2..53f8210 100644 --- a/controllers/front/cancel.php +++ b/controllers/front/cancel.php @@ -18,54 +18,235 @@ 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)$this->module->resolveTwoAttemptOrderIdForCancellation($attempt); + 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', array( + 'id_order' => $attempt_order_id, + )); + $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)) { + $resolved_terms = $this->module->resolveTwoPaymentTermsFromOrderResponse( + $response, + isset($attempt['two_day_on_invoice']) ? (string)$attempt['two_day_on_invoice'] : (string)$this->module->getSelectedPaymentTerm(), + isset($attempt['two_payment_term_type']) ? $attempt['two_payment_term_type'] : Configuration::get('PS_TWO_PAYMENT_TERM_TYPE') + ); + $extra_data['two_order_state'] = isset($response['state']) ? $response['state'] : ''; + $extra_data['two_order_status'] = isset($response['status']) ? $response['status'] : ''; + $extra_data['two_day_on_invoice'] = $resolved_terms['two_day_on_invoice']; + $extra_data['two_payment_term_type'] = $resolved_terms['two_payment_term_type']; + 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->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'); - } else { - $message = $this->module->l('Unable to find the requested order please contact store owner.'); + } + + $customer = new Customer((int)$order->id_customer); + if (!Validate::isLoadedObject($customer)) { + $message = $this->module->l('Unable to load order customer.'); $this->errors[] = $message; $this->redirectWithNotifications('index.php?controller=order'); } + + if (!$this->isAuthorizedLegacyOrderAccess($order, $customer)) { + PrestaShopLogger::addLog( + 'TwoPayment: Unauthorized legacy cancel callback for order ' . (int)$order->id, + 3 + ); + $message = $this->module->l('Unable to validate this cancellation callback. Please retry checkout.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $orderpaymentdata = $this->module->getTwoOrderPaymentData($id_order); + if ($orderpaymentdata && isset($orderpaymentdata['two_order_id'])) { + $two_order_id = $orderpaymentdata['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; + + $response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); + $response_http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; + $provider_cancelled = $this->module->isTwoOrderCancelledResponse($response, $response_http_status); + + if ($provider_cancelled) { + $resolved_terms = $this->module->resolveTwoPaymentTermsFromOrderResponse( + $response, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->module->getSelectedPaymentTerm(), + isset($orderpaymentdata['two_payment_term_type']) ? $orderpaymentdata['two_payment_term_type'] : Configuration::get('PS_TWO_PAYMENT_TERM_TYPE') + ); + $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' => $resolved_terms['two_day_on_invoice'], + 'two_invoice_url' => $response['invoice_url'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], + ); + $this->module->setTwoOrderPaymentData($order->id, $payment_data); + } else { + PrestaShopLogger::addLog( + 'TwoPayment: Legacy cancel callback could not confirm CANCELLED provider state for order ' . (int)$order->id . + ', Two order ' . $two_order_id . + ', cancel_http=' . $cancel_http_status . + ', fetch_http=' . $response_http_status . + ', provider_state=' . (isset($response['state']) ? (string)$response['state'] : 'unknown'), + 2 + ); + $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'); + } + } + + $this->module->restoreDuplicateCart($order->id, $order->id_customer); + $this->module->changeOrderStatus($order->id, $this->getCancelledStatus()); + + $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 + ); + } + + /** + * Validate legacy callback authorization for order-based cancellation paths. + * + * @param Order $order + * @param Customer $customer + * @return bool + */ + private function isAuthorizedLegacyOrderAccess($order, $customer) + { + if (!Validate::isLoadedObject($order) || !Validate::isLoadedObject($customer)) { + return false; + } + + $expected_secure_key = trim((string)$customer->secure_key); + if (Tools::isEmpty($expected_secure_key)) { + return false; + } + + $provided_key = trim((string)Tools::getValue('key')); + if (!Tools::isEmpty($provided_key)) { + return hash_equals($expected_secure_key, $provided_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) ? trim((string)$this->context->customer->secure_key) : ''; + + return $context_customer_id === (int)$order->id_customer && + !Tools::isEmpty($context_customer_secure_key) && + hash_equals($expected_secure_key, $context_customer_secure_key); } } diff --git a/controllers/front/confirmation.php b/controllers/front/confirmation.php index d89265d..ba5758a 100644 --- a/controllers/front/confirmation.php +++ b/controllers/front/confirmation.php @@ -18,61 +18,628 @@ 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->handleAttemptTokenConfirmation($attempt_token); + return; + } - if (isset($id_order) && !Tools::isEmpty($id_order)) { - $order = new Order((int) $id_order); - $customer = new Customer($order->id_customer); - - $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, [], 'GET'); - $two_err = $this->module->getTwoErrorMessage($response); - if ($two_err) { - $this->module->restoreDuplicateCart($order->id, $customer->id); - $this->module->changeOrderStatus($order->id, Configuration::get('PS_TWO_OS_PAYMENT_ERROR_MAP')); - $message = ($two_err != '') ? $two_err : $this->module->l('Unable to retrieve the order payment information please contact store owner.'); + $id_order = (int)Tools::getValue('id_order'); + if ($id_order > 0) { + $this->handleLegacyOrderConfirmation($id_order); + return; + } + + $message = $this->module->l('Unable to find the requested order please contact store owner.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order&step=1'); + } + + private function handleAttemptTokenConfirmation($attempt_token) + { + $attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (!$attempt) { + $message = $this->module->l('Unable to find the requested payment attempt. Please try checkout again.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + if (!$this->isAuthorizedAttemptCallback($attempt)) { + PrestaShopLogger::addLog( + 'TwoPayment: Unauthorized confirmation callback for attempt ' . $attempt_token, + 3 + ); + $message = $this->module->l('Unable to validate this payment callback. Please retry checkout.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $customer = new Customer((int)$attempt['id_customer']); + if (!Validate::isLoadedObject($customer)) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = $this->module->l('Unable to load customer for this payment attempt.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + } + + $existing_attempt_order_id = (int)$attempt['id_order']; + if ($existing_attempt_order_id > 0) { + $existing_order = new Order($existing_attempt_order_id); + if (Validate::isLoadedObject($existing_order)) { + $existing_payment_data = $this->module->getTwoOrderPaymentData($existing_order->id); + if ($existing_payment_data && isset($existing_payment_data['two_order_id'])) { + $sync_ok = $this->syncTwoMerchantOrderId($existing_order, $existing_payment_data); + if ($sync_ok) { + $this->module->setTwoCheckoutAttemptMerchantOrderId($attempt_token, (string)$existing_order->id); + } + } + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + } + $this->redirectToOrderConfirmation($existing_order, $customer); + } + } + + $cart = new Cart((int)$attempt['id_cart']); + if (!Validate::isLoadedObject($cart)) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = $this->module->l('Unable to load cart for this payment attempt.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + // Ensure checkout context matches the attempt before creating a local order. + $this->context->cart = $cart; + $this->context->customer = $customer; + $this->context->currency = new Currency((int)$cart->id_currency); + $this->context->cookie->id_cart = (int)$cart->id; + $this->context->cookie->id_customer = (int)$customer->id; + $this->context->cookie->write(); + + if (empty($attempt['two_order_id'])) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = $this->module->l('Missing provider order reference for this attempt.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $stored_snapshot_hash = isset($attempt['cart_snapshot_hash']) ? trim((string)$attempt['cart_snapshot_hash']) : ''; + if (!Tools::isEmpty($stored_snapshot_hash)) { + try { + $current_snapshot_hash = $this->buildAttemptSnapshotHash($attempt_token, $attempt, $cart, true); + } catch (Exception $e) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + PrestaShopLogger::addLog( + 'TwoPayment: Failed to build cart snapshot for attempt ' . $attempt_token . ' - ' . $e->getMessage(), + 3 + ); + $message = $this->module->l('Unable to validate cart consistency for this payment. Please try again.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + if (!hash_equals($stored_snapshot_hash, $current_snapshot_hash)) { + // Compatibility fallback: allow old attempts created before callback key was added. + try { + $legacy_snapshot_hash = $this->buildAttemptSnapshotHash($attempt_token, $attempt, $cart, false); + } catch (Exception $e) { + $legacy_snapshot_hash = ''; + } + + if (!Tools::isEmpty($legacy_snapshot_hash) && hash_equals($stored_snapshot_hash, $legacy_snapshot_hash)) { + $current_snapshot_hash = $legacy_snapshot_hash; + } else { + // Cart changed between provider order creation and callback finalization. + $this->module->setTwoPaymentRequest('/v1/order/' . $attempt['two_order_id'] . '/cancel', [], 'POST'); + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + PrestaShopLogger::addLog( + 'TwoPayment: Cart snapshot mismatch for attempt ' . $attempt_token . '. Stored=' . $stored_snapshot_hash . ', Current=' . $current_snapshot_hash, + 3 + ); + $message = $this->module->l('Your cart changed during payment verification. Please review your cart and try again.'); $this->errors[] = $message; $this->redirectWithNotifications('index.php?controller=order'); } + } + } - if (isset($response['state']) && $response['state'] == 'VERIFIED') { - // Order is verified, now confirm it to move to CONFIRMED state - $confirm_result = $this->module->confirmTwoOrder($two_order_id); - - // Use the confirmation result or fallback to original state - $final_state = $confirm_result['success'] ? $confirm_result['state'] : $response['state']; - $final_status = ($confirm_result['success'] && $confirm_result['status']) ? $confirm_result['status'] : $response['status']; - - $payment_data = array( - 'two_order_id' => $response['id'], - 'two_order_reference' => $response['merchant_reference'], - 'two_order_state' => $final_state, - 'two_order_status' => $final_status, - 'two_day_on_invoice' => (string)$this->module->getSelectedPaymentTerm(), // Selected payment term - 'two_payment_term_type' => Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'), // Term type (STANDARD or EOM) - 'two_invoice_url' => $response['invoice_url'], - ); - $this->module->setTwoOrderPaymentData($order->id, $payment_data); + $two_order_id = $attempt['two_order_id']; + $response = $this->module->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); + $two_err = $this->module->getTwoErrorMessage($response); + if ($two_err) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = ($two_err != '') ? $two_err : $this->module->l('Unable to retrieve the order payment information please contact store owner.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $two_state = isset($response['state']) ? strtoupper((string)$response['state']) : ''; + $valid_states = array('VERIFIED', 'CONFIRMED', 'FULFILLED'); + if (!in_array($two_state, $valid_states, true)) { + $extra_data = array( + 'two_order_state' => isset($response['state']) ? $response['state'] : '', + 'two_order_status' => isset($response['status']) ? $response['status'] : '', + ); + + $resolved_order_id = (int)$this->module->resolveTwoAttemptOrderIdForCancellation($attempt); + if ($two_state === 'CANCELLED') { + if ($resolved_order_id > 0) { + $extra_data['id_order'] = $resolved_order_id; } + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'CANCELLED', $extra_data); + $this->module->syncLocalOrderStatusFromTwoState($resolved_order_id, $two_state); + $message = $this->module->l('Your order is cancelled.'); + } else { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED', $extra_data); + $message = $this->module->l('Payment has not been verified yet. Please try again or contact support.'); } - // Use custom Two state if available, fallback to mapped state - $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT'); - if (!$verified_status) { - $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT_MAP'); - if (!$verified_status) { - $verified_status = Configuration::get('PS_OS_PREPARATION'); + + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $final_state = isset($response['state']) ? $response['state'] : 'VERIFIED'; + $final_status = isset($response['status']) ? $response['status'] : null; + if ($two_state === 'VERIFIED') { + $confirm_result = $this->module->confirmTwoOrder($two_order_id); + if ($confirm_result['success']) { + $final_state = $confirm_result['state']; + $final_status = $confirm_result['status'] ?: $final_status; + } + } + + $provider_gross_amount = $this->module->extractTwoProviderGrossAmountForValidation($response); + if ($provider_gross_amount === null) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + PrestaShopLogger::addLog( + 'TwoPayment: Missing or invalid provider gross_amount in callback response for attempt ' . $attempt_token, + 3 + ); + $message = $this->module->l('Unable to retrieve the order payment information please contact store owner.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + return; + } + + // Re-read latest attempt status to close cancel/confirm race windows. + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + } + + $id_order = (int)$attempt['id_order']; + if ($id_order <= 0) { + $id_order = (int)$this->module->getTwoOrderIdByCart((int)$cart->id); + } + + if ($id_order > 0) { + $existing_payment_data = $this->module->getTwoOrderPaymentData((int)$id_order); + if ($this->module->hasTwoOrderRebindingConflict($existing_payment_data, (string)$attempt['two_order_id'])) { + $existing_two_order_id = isset($existing_payment_data['two_order_id']) ? (string)$existing_payment_data['two_order_id'] : ''; + PrestaShopLogger::addLog( + 'TwoPayment: Rebinding guard blocked callback for attempt ' . $attempt_token . + '. Existing order ' . (int)$id_order . ' already linked to Two order ' . $existing_two_order_id . + ', incoming Two order ' . (string)$attempt['two_order_id'], + 3 + ); + + // Best effort: cancel incoming duplicate provider order to avoid orphaned external state. + if (!Tools::isEmpty($attempt['two_order_id'])) { + $this->module->setTwoPaymentRequest('/v1/order/' . $attempt['two_order_id'] . '/cancel', [], 'POST'); } + + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $existing_order = new Order((int)$id_order); + if (Validate::isLoadedObject($existing_order)) { + $this->redirectToOrderConfirmation($existing_order, $customer); + } + + $message = $this->module->l('This payment attempt has already been finalized.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + } + + if ($id_order <= 0) { + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + } + + $initial_status = $this->getInitialAwaitingStatus(); + $create_result = $this->module->createTwoLocalOrderAfterProviderVerification( + $cart, + $customer, + (int)$initial_status, + (float)$provider_gross_amount + ); + + if (!(bool)$create_result['success']) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $error_code = isset($create_result['error']) ? (string)$create_result['error'] : ''; + if ($error_code === 'currency_invalid') { + $message = $this->module->l('Unable to load currency for this payment attempt.'); + } else { + $message = $this->module->l('Unable to create local order for this payment attempt.'); + } + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + $id_order = isset($create_result['id_order']) ? (int)$create_result['id_order'] : 0; + if ((bool)(isset($create_result['recovered_existing']) ? $create_result['recovered_existing'] : false)) { + PrestaShopLogger::addLog( + 'TwoPayment: Recovered existing local order ' . $id_order . + ' for callback attempt ' . $attempt_token . ' after validateOrder race/duplicate.', + 2 + ); + } + } + + if ($id_order <= 0) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = $this->module->l('Unable to create local order for this payment attempt.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $order = new Order((int)$id_order); + if (!Validate::isLoadedObject($order)) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + $message = $this->module->l('Unable to load the created order. Please contact support.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; } - $this->module->changeOrderStatus($order->id, $verified_status); - Tools::redirect('index.php?controller=order-confirmation&id_cart=' . $order->id_cart . '&id_module=' . $this->module->id . '&id_order=' . $order->id . '&key=' . $customer->secure_key); - } else { + } + + $invoice_id = isset($response['invoice_details']['id']) ? $response['invoice_details']['id'] : $attempt['two_invoice_id']; + $invoice_url = isset($response['invoice_url']) ? $response['invoice_url'] : $attempt['two_invoice_url']; + $resolved_terms = $this->module->resolveTwoPaymentTermsFromOrderResponse( + $response, + isset($attempt['two_day_on_invoice']) ? (string)$attempt['two_day_on_invoice'] : (string)$this->module->getSelectedPaymentTerm(), + isset($attempt['two_payment_term_type']) ? $attempt['two_payment_term_type'] : Configuration::get('PS_TWO_PAYMENT_TERM_TYPE') + ); + $payment_data = array( + 'two_order_id' => isset($response['id']) ? $response['id'] : $attempt['two_order_id'], + 'two_order_reference' => isset($response['merchant_reference']) ? $response['merchant_reference'] : $attempt['two_order_reference'], + 'two_order_state' => $final_state, + 'two_order_status' => $final_status, + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], + 'two_invoice_url' => $invoice_url, + 'two_invoice_id' => $invoice_id, + ); + $this->module->setTwoOrderPaymentData($order->id, $payment_data); + + // Best effort: replace provisional merchant_order_id with real PrestaShop id_order in Two. + $sync_ok = $this->syncTwoMerchantOrderId($order, $payment_data); + if ($sync_ok) { + $this->module->setTwoCheckoutAttemptMerchantOrderId($attempt_token, (string)$order->id); + } + + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'CONFIRMED', array( + 'id_order' => (int)$order->id, + 'two_order_state' => $payment_data['two_order_state'], + 'two_order_status' => $payment_data['two_order_status'], + 'two_day_on_invoice' => $payment_data['two_day_on_invoice'], + 'two_payment_term_type' => $payment_data['two_payment_term_type'], + 'two_invoice_url' => $payment_data['two_invoice_url'], + 'two_invoice_id' => $payment_data['two_invoice_id'], + )); + + $latest_attempt = $this->module->getTwoCheckoutAttempt($attempt_token); + if (is_array($latest_attempt)) { + $attempt = $latest_attempt; + if ($this->abortConfirmationIfAttemptCancelled($attempt_token, $attempt)) { + return; + } + } + + $this->module->changeOrderStatus($order->id, $this->getVerifiedStatus()); + $this->redirectToOrderConfirmation($order, $customer); + } + + /** + * Best effort sync of merchant_order_id in Two to the real PrestaShop order ID. + * Never blocks customer confirmation if provider update fails. + */ + private function syncTwoMerchantOrderId($order, $payment_data) + { + if (!Validate::isLoadedObject($order) || !is_array($payment_data) || empty($payment_data['two_order_id'])) { + return false; + } + + try { + $update_payload = $this->module->getTwoUpdateOrderData($order, $payment_data); + $update_payload['merchant_order_id'] = (string)$order->id; + + $update_response = $this->module->setTwoPaymentRequest( + '/v1/order/' . $payment_data['two_order_id'], + $update_payload, + 'PUT' + ); + + $http_status = isset($update_response['http_status']) ? (int)$update_response['http_status'] : 0; + if ($http_status === Twopayment::HTTP_STATUS_OK) { + PrestaShopLogger::addLog( + 'TwoPayment: Synced merchant_order_id=' . $order->id . ' to Two order ' . $payment_data['two_order_id'], + 1, + null, + 'Order', + $order->id + ); + return true; + } + + $sync_error = $this->module->getTwoErrorMessage($update_response); + PrestaShopLogger::addLog( + 'TwoPayment: Failed to sync merchant_order_id for order ' . $order->id . + ', Two order ' . $payment_data['two_order_id'] . + ', HTTP ' . $http_status . + ($sync_error ? ', Error: ' . $sync_error : ''), + 2, + null, + 'Order', + $order->id + ); + } catch (Exception $e) { + PrestaShopLogger::addLog( + 'TwoPayment: Exception syncing merchant_order_id for order ' . $order->id . + ' - ' . $e->getMessage(), + 2, + null, + 'Order', + $order->id + ); + } + + return false; + } + + /** + * 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 + ); + } + + /** + * Rebuild snapshot hash using current cart and expected callback URL format. + */ + private function buildAttemptSnapshotHash($attempt_token, $attempt, $cart, $include_secure_key) + { + $confirm_params = array('attempt_token' => $attempt_token); + $cancel_params = array('attempt_token' => $attempt_token); + if ($include_secure_key && !Tools::isEmpty($attempt['customer_secure_key'])) { + $confirm_params['key'] = $attempt['customer_secure_key']; + $cancel_params['key'] = $attempt['customer_secure_key']; + } + + $comparison_urls = array( + 'merchant_confirmation_url' => $this->context->link->getModuleLink($this->module->name, 'confirmation', $confirm_params, true), + 'merchant_cancel_order_url' => $this->context->link->getModuleLink($this->module->name, 'cancel', $cancel_params, true), + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ); + $comparison_payload = $this->module->getTwoNewOrderData($attempt['merchant_order_id'], $cart, $comparison_urls); + + return $this->module->calculateTwoCheckoutSnapshotHash($cart, $comparison_payload); + } + + private function handleLegacyOrderConfirmation($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&step=1'); } + + $customer = new Customer($order->id_customer); + if (!Validate::isLoadedObject($customer)) { + $message = $this->module->l('Unable to load order customer.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + if (!$this->isAuthorizedLegacyOrderAccess($order, $customer)) { + PrestaShopLogger::addLog( + 'TwoPayment: Unauthorized legacy confirmation callback for order ' . (int)$order->id, + 3 + ); + $message = $this->module->l('Unable to validate this payment callback. Please retry checkout.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $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, [], 'GET'); + $two_err = $this->module->getTwoErrorMessage($response); + if ($two_err) { + $this->module->restoreDuplicateCart($order->id, $customer->id); + $this->module->changeOrderStatus($order->id, Configuration::get('PS_TWO_OS_PAYMENT_ERROR_MAP')); + $message = ($two_err != '') ? $two_err : $this->module->l('Unable to retrieve the order payment information please contact store owner.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + $two_state = isset($response['state']) ? strtoupper(trim((string)$response['state'])) : ''; + if ($two_state === 'CANCELLED') { + $this->module->syncLocalOrderStatusFromTwoState((int)$order->id, 'CANCELLED'); + $message = $this->module->l('Your order is cancelled.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + } + + if ($two_state === 'VERIFIED') { + // Order is verified, now confirm it to move to CONFIRMED state + $confirm_result = $this->module->confirmTwoOrder($two_order_id); + + // Use the confirmation result or fallback to original state + $final_state = $confirm_result['success'] ? $confirm_result['state'] : $response['state']; + $final_status = ($confirm_result['success'] && $confirm_result['status']) ? $confirm_result['status'] : $response['status']; + $resolved_terms = $this->module->resolveTwoPaymentTermsFromOrderResponse( + $response, + (string)$this->module->getSelectedPaymentTerm(), + Configuration::get('PS_TWO_PAYMENT_TERM_TYPE') + ); + + $payment_data = array( + 'two_order_id' => $response['id'], + 'two_order_reference' => $response['merchant_reference'], + 'two_order_state' => $final_state, + 'two_order_status' => $final_status, + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], + 'two_invoice_url' => $response['invoice_url'], + ); + $this->module->setTwoOrderPaymentData($order->id, $payment_data); + } + } + + $this->module->changeOrderStatus($order->id, $this->getVerifiedStatus()); + $this->redirectToOrderConfirmation($order, $customer); + } + + /** + * Validate legacy callback authorization for order-based confirmation paths. + * + * @param Order $order + * @param Customer $customer + * @return bool + */ + private function isAuthorizedLegacyOrderAccess($order, $customer) + { + if (!Validate::isLoadedObject($order) || !Validate::isLoadedObject($customer)) { + return false; + } + + $expected_secure_key = trim((string)$customer->secure_key); + if (Tools::isEmpty($expected_secure_key)) { + return false; + } + + $provided_key = trim((string)Tools::getValue('key')); + if (!Tools::isEmpty($provided_key)) { + return hash_equals($expected_secure_key, $provided_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) ? trim((string)$this->context->customer->secure_key) : ''; + + return $context_customer_id === (int)$order->id_customer && + !Tools::isEmpty($context_customer_secure_key) && + hash_equals($expected_secure_key, $context_customer_secure_key); + } + + private function getInitialAwaitingStatus() + { + $initial_status = Configuration::get('PS_TWO_OS_AWAITING_VERIFICATION'); + if (!$initial_status) { + $initial_status = Configuration::get('PS_TWO_OS_AWAITING_VERIFICATION_MAP'); + if (!$initial_status) { + $initial_status = Configuration::get('PS_OS_PREPARATION'); + } + } + return (int)$initial_status; + } + + private function getVerifiedStatus() + { + $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT'); + if (!$verified_status) { + $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT_MAP'); + if (!$verified_status) { + $verified_status = Configuration::get('PS_OS_PREPARATION'); + } + } + return (int)$verified_status; + } + + /** + * Stop confirmation flow when attempt is already cancelled. + * + * @param string $attempt_token + * @param array $attempt + * @return bool True when flow was aborted + */ + private function abortConfirmationIfAttemptCancelled($attempt_token, $attempt) + { + $attempt_status = isset($attempt['status']) ? (string)$attempt['status'] : ''; + if (!$this->module->shouldBlockTwoAttemptConfirmationByStatus($attempt_status)) { + return false; + } + + $resolved_order_id = (int)$this->module->resolveTwoAttemptOrderIdForCancellation($attempt); + if ($resolved_order_id > 0) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'CANCELLED', array( + 'id_order' => $resolved_order_id, + )); + $this->module->syncLocalOrderStatusFromTwoState($resolved_order_id, 'CANCELLED'); + } + + if (!empty($attempt['two_order_id'])) { + $this->module->cancelTwoOrderBestEffort((string)$attempt['two_order_id'], 'confirmation_after_cancelled_attempt'); + } + + $message = $this->module->l('Your order is cancelled.'); + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + + return true; + } + + private function redirectToOrderConfirmation($order, $customer) + { + Tools::redirect( + 'index.php?controller=order-confirmation&id_cart=' . (int)$order->id_cart . + '&id_module=' . (int)$this->module->id . + '&id_order=' . (int)$order->id . + '&key=' . $customer->secure_key + ); } } diff --git a/controllers/front/orderintent.php b/controllers/front/orderintent.php index ece819f..3b841bb 100644 --- a/controllers/front/orderintent.php +++ b/controllers/front/orderintent.php @@ -70,7 +70,7 @@ public function displayAjax() default: $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Unknown action: ' . $action + 'error' => $this->module->l('Unknown action requested.') ])); } } @@ -81,16 +81,16 @@ public function displayAjax() public function ajaxProcessSavePaymentTerm() { if (!$this->validateAjaxToken()) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Invalid token'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Invalid token')])); return; } if (!$this->isPost()) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Only POST requests allowed'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Only POST requests allowed')])); return; } $days = (int)Tools::getValue('days'); if ($days <= 0) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Invalid days'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Invalid days')])); return; } $this->context->cookie->two_payment_term = $days; @@ -105,16 +105,23 @@ public function ajaxProcessSavePaymentTerm() public function ajaxProcessSaveCompany() { if (!$this->validateAjaxToken()) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Invalid token'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Invalid token')])); return; } $company = trim(Tools::getValue('company', '')); $companyId = trim(Tools::getValue('companyid', '')); $country = trim(Tools::getValue('country', '')); + $addressId = (int) Tools::getValue('id_address', 0); + if ($addressId <= 0 && Validate::isLoadedObject($this->context->cart)) { + $addressId = (int) $this->context->cart->id_address_invoice; + if ($addressId <= 0) { + $addressId = (int) $this->context->cart->id_address_delivery; + } + } if (empty($company) || empty($companyId)) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Missing company data'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Missing company data')])); return; } @@ -123,6 +130,9 @@ public function ajaxProcessSaveCompany() if (!empty($country)) { $this->context->cookie->two_company_country = $country; } + if ($addressId > 0) { + $this->context->cookie->two_company_address_id = (string) $addressId; + } $this->context->cookie->setExpire(time() + Twopayment::COOKIE_EXPIRY_ONE_HOUR); PrestaShopLogger::addLog('TwoPayment: Saved company in cookie for session', 1); $this->sendJsonResponse(json_encode(['success' => true])); @@ -134,17 +144,19 @@ public function ajaxProcessSaveCompany() public function ajaxProcessGetCompany() { if (!$this->validateAjaxToken()) { - $this->sendJsonResponse(json_encode(['success' => false, 'error' => 'Invalid token'])); + $this->sendJsonResponse(json_encode(['success' => false, 'error' => $this->module->l('Invalid token')])); return; } $company = isset($this->context->cookie->two_company_name) ? $this->context->cookie->two_company_name : ''; $companyId = isset($this->context->cookie->two_company_id) ? $this->context->cookie->two_company_id : ''; $companyCountry = isset($this->context->cookie->two_company_country) ? $this->context->cookie->two_company_country : ''; + $companyAddressId = isset($this->context->cookie->two_company_address_id) ? (int) $this->context->cookie->two_company_address_id : 0; $this->sendJsonResponse(json_encode([ 'success' => true, 'company' => $company, 'companyid' => $companyId, - 'country' => $companyCountry + 'country' => $companyCountry, + 'address_id' => $companyAddressId ])); } @@ -156,7 +168,6 @@ public function initContent() // If this is a direct access (not AJAX), return simple response if (!Tools::getValue('ajax')) { - die; exit; } @@ -170,11 +181,11 @@ public function initContent() */ public function ajaxProcessCheckOrderIntent() { - // Rate limiting protection - max 3 requests per minute per session + // Rate limiting protection if (!$this->checkRateLimit()) { $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Too many requests. Please wait and try again.' + 'error' => $this->module->l('Too many requests. Please wait and try again.') ])); return; } @@ -183,7 +194,7 @@ public function ajaxProcessCheckOrderIntent() if (!$this->validateAjaxToken()) { $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Invalid token' + 'error' => $this->module->l('Invalid token') ])); return; } @@ -192,7 +203,7 @@ public function ajaxProcessCheckOrderIntent() if (!$this->isPost()) { $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Only POST requests allowed' + 'error' => $this->module->l('Only POST requests allowed') ])); return; } @@ -203,11 +214,15 @@ public function ajaxProcessCheckOrderIntent() $customer = new Customer($cart->id_customer); $currency = new Currency($cart->id_currency); - // CRITICAL FIX: Use the address ID that JavaScript sends, not hardcoded invoice address - $addressId = (int)Tools::getValue('id_address_delivery'); + // Use invoice/billing address as authoritative company identity source. + $addressId = (int)Tools::getValue('id_address_invoice'); if (empty($addressId)) { - // Fallback to delivery address from cart, then invoice address - $addressId = $cart->id_address_delivery ?: $cart->id_address_invoice; + // Backward compatibility: older clients may still send delivery id only. + $addressId = (int)Tools::getValue('id_address_delivery'); + } + if (empty($addressId)) { + // Fallback to invoice address from cart, then delivery address. + $addressId = $cart->id_address_invoice ?: $cart->id_address_delivery; } $address = new Address($addressId); @@ -218,7 +233,7 @@ public function ajaxProcessCheckOrderIntent() PrestaShopLogger::addLog('TwoPayment: Invalid cart, customer, or address data in order intent (address ID: ' . $addressId . ')', 3); $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Invalid cart or customer data' + 'error' => $this->module->l('Invalid cart or customer data') ])); return; } @@ -227,9 +242,13 @@ public function ajaxProcessCheckOrderIntent() $companyData = $this->getCompanyDataWithFallbacks(); $companyName = $companyData['company']; $companyId = $companyData['companyid']; - // Determine account type. When admin disabled account type, treat as business at payment step but relax earlier steps on FE. + // Determine account type only when merchant explicitly enabled account-type mode. + // In strict account-type mode, missing/invalid account_type must be blocked. $useAccountType = (int)Configuration::get('PS_TWO_USE_ACCOUNT_TYPE'); - $accountType = $useAccountType ? 'business' : 'business'; + $accountType = 'business'; + if ($useAccountType) { + $accountType = property_exists($address, 'account_type') ? trim((string) $address->account_type) : ''; + } // Store company data in PrestaShop session for future use $this->storeCompanyDataInSession($companyData); @@ -265,7 +284,7 @@ public function ajaxProcessCheckOrderIntent() PrestaShopLogger::addLog('TwoPayment: Order intent blocked - non-business account type: ' . $accountType, 2); $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Two payment is only available for business accounts' + 'error' => $this->module->l('Two payment is only available for business accounts') ])); return; } @@ -290,7 +309,7 @@ public function ajaxProcessCheckOrderIntent() $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Failed to build order intent payload' + 'error' => $this->module->l('Failed to build order intent payload') ])); return; } @@ -306,15 +325,15 @@ public function ajaxProcessBuildPayload() } /** - * Save order intent result to session for server-side validation - * Called when client receives order intent result from Two API + * Save frontend order intent result in session as telemetry only. + * Authoritative approval is revalidated server-side on payment submit. */ public function ajaxProcessSaveOrderIntentResult() { if (!$this->validateAjaxToken()) { $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Invalid token' + 'error' => $this->module->l('Invalid token') ])); return; } @@ -329,10 +348,7 @@ public function ajaxProcessSaveOrderIntentResult() // Write cookie to ensure it's saved $this->context->cookie->write(); - PrestaShopLogger::addLog( - 'TwoPayment: Order intent result saved to session - Approved: ' . ($approved ? 'yes' : 'no') . ', Timestamp: ' . $timestamp, - 1 - ); + PrestaShopLogger::addLog('TwoPayment: Order intent telemetry saved in session', 1); $this->sendJsonResponse(json_encode([ 'success' => true, @@ -342,7 +358,7 @@ public function ajaxProcessSaveOrderIntentResult() } /** - * Clear order intent result from session + * Clear order intent telemetry from session * Called when user switches away from Two payment method */ public function ajaxProcessClearOrderIntentResult() @@ -350,7 +366,7 @@ public function ajaxProcessClearOrderIntentResult() if (!$this->validateAjaxToken()) { $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Invalid token' + 'error' => $this->module->l('Invalid token') ])); return; } @@ -377,38 +393,38 @@ private function isPost() /** * Rate limiting check - prevent abuse of order intent API - * Max 3 requests per minute per session + * Max 5 requests per minute per checkout cookie session */ private function checkRateLimit() { - $session_id = session_id(); - if (empty($session_id)) { - // If no session, use IP as fallback (less reliable but better than nothing) - $session_id = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - } - - $rate_limit_key = 'two_order_intent_' . md5($session_id); $current_time = time(); - $rate_limit_window = Twopayment::API_TIMEOUT_SHORT; // 1 minute (using API_TIMEOUT_SHORT constant) + $rate_limit_window = 60; $max_requests = 5; // Production rate limit - - // Get current request data from session - $request_data = isset($_SESSION[$rate_limit_key]) ? $_SESSION[$rate_limit_key] : []; - - // Clean old requests outside the window - $request_data = array_filter($request_data, function($timestamp) use ($current_time, $rate_limit_window) { - return ($current_time - $timestamp) < $rate_limit_window; - }); - + + $request_data = array(); + $encoded = isset($this->context->cookie->two_order_intent_rate_limit) ? (string)$this->context->cookie->two_order_intent_rate_limit : ''; + if (!Tools::isEmpty($encoded)) { + $decoded = json_decode($encoded, true); + if (is_array($decoded)) { + foreach ($decoded as $timestamp) { + $timestamp = (int)$timestamp; + if ($timestamp > 0 && ($current_time - $timestamp) < $rate_limit_window) { + $request_data[] = $timestamp; + } + } + } + } + // Check if we're over the limit if (count($request_data) >= $max_requests) { return false; } - + // Add current request $request_data[] = $current_time; - $_SESSION[$rate_limit_key] = $request_data; - + $this->context->cookie->two_order_intent_rate_limit = json_encode(array_values($request_data)); + $this->context->cookie->write(); + return true; } @@ -470,9 +486,46 @@ private function getCompanyDataWithFallbacks() return ['company' => $company, 'companyid' => $companyId]; } + // Resolve selected checkout address first (prefer request-provided delivery address). + $selectedAddressId = (int) Tools::getValue('id_address_invoice'); + if ($selectedAddressId <= 0) { + $selectedAddressId = (int) Tools::getValue('id_address_delivery'); + } + if ($selectedAddressId <= 0) { + $selectedAddressId = (int) $this->context->cart->id_address_invoice; + } + if ($selectedAddressId <= 0) { + $selectedAddressId = (int) $this->context->cart->id_address_delivery; + } + // Priority 2: PrestaShop session/cookie (persisted from previous steps or company search) - $sessionCompany = isset($this->context->cookie->two_company_name) ? trim($this->context->cookie->two_company_name) : ''; - $sessionCompanyId = isset($this->context->cookie->two_company_id) ? trim($this->context->cookie->two_company_id) : ''; + // Validate session company country against the current selected address country. + $currentCountryIso = ''; + if ($selectedAddressId > 0) { + $selectedAddress = new Address($selectedAddressId); + if (Validate::isLoadedObject($selectedAddress)) { + $countryIsoCandidate = Country::getIsoById($selectedAddress->id_country); + if ($countryIsoCandidate && is_string($countryIsoCandidate)) { + $currentCountryIso = $countryIsoCandidate; + } + } + } + $validatedSession = $this->module->getTwoValidatedSessionCompanyData($currentCountryIso); + $sessionCompany = isset($validatedSession['company_name']) ? trim($validatedSession['company_name']) : ''; + $sessionCompanyId = isset($validatedSession['organization_number']) ? trim($validatedSession['organization_number']) : ''; + $sessionAddressId = isset($this->context->cookie->two_company_address_id) + ? (int) $this->context->cookie->two_company_address_id + : 0; + + if ($sessionAddressId > 0 && $selectedAddressId > 0 && $sessionAddressId !== $selectedAddressId) { + PrestaShopLogger::addLog( + 'TwoPayment: Ignoring session company due to address switch in order intent. Session address=' . + $sessionAddressId . ', selected address=' . $selectedAddressId, + 2 + ); + $sessionCompany = ''; + $sessionCompanyId = ''; + } if (!empty($sessionCompany) && !empty($sessionCompanyId)) { PrestaShopLogger::addLog('TwoPayment: Company data retrieved from PrestaShop session (complete)', 1); @@ -484,72 +537,70 @@ private function getCompanyDataWithFallbacks() // Priority 3: Customer's address with ORG NUMBER VERIFICATION via Two API // This is the KEY FIX - we look for org numbers in address fields and verify them - if ($this->context->customer->isLogged()) { - $address = new Address($this->context->cart->id_address_invoice); - if (Validate::isLoadedObject($address)) { - $countryIso = Country::getIsoById($address->id_country); + $address = new Address($selectedAddressId); + if (Validate::isLoadedObject($address)) { + $countryIso = Country::getIsoById($address->id_country); + + if ($countryIso && is_string($countryIso)) { + // STEP 1: Try to extract organization number from address fields + // This checks dni, vat_number, companyid fields + $existingOrgNumber = $this->module->extractOrgNumberFromAddress($address, $countryIso); - if ($countryIso && is_string($countryIso)) { - // STEP 1: Try to extract organization number from address fields - // This checks dni, vat_number, companyid fields - $existingOrgNumber = $this->module->extractOrgNumberFromAddress($address, $countryIso); + if (!empty($existingOrgNumber)) { + // STEP 2: Verify the org number via Two's API to get company name + // This gives us an EXACT match - no vagueness like name-based search + PrestaShopLogger::addLog( + 'TwoPayment: Found org number in address (' . $existingOrgNumber . '), verifying via Two API', + 1 + ); - if (!empty($existingOrgNumber)) { - // STEP 2: Verify the org number via Two's API to get company name - // This gives us an EXACT match - no vagueness like name-based search - PrestaShopLogger::addLog( - 'TwoPayment: Found org number in address (' . $existingOrgNumber . '), verifying via Two API', - 1 - ); + $verifiedCompany = $this->module->verifyCompanyByOrgNumber($existingOrgNumber, $countryIso); + + if ($verifiedCompany && !empty($verifiedCompany['organization_number'])) { + // SUCCESS! We have verified company data from existing address + $resolvedCompany = $verifiedCompany['name']; + $resolvedOrgNumber = $verifiedCompany['organization_number']; - $verifiedCompany = $this->module->verifyCompanyByOrgNumber($existingOrgNumber, $countryIso); + // Cache in session for future requests + $this->context->cookie->two_company_name = $resolvedCompany; + $this->context->cookie->two_company_id = $resolvedOrgNumber; + $this->context->cookie->two_company_country = $countryIso; + $this->context->cookie->setExpire(time() + Twopayment::COOKIE_EXPIRY_ONE_HOUR); - if ($verifiedCompany && !empty($verifiedCompany['organization_number'])) { - // SUCCESS! We have verified company data from existing address - $resolvedCompany = $verifiedCompany['name']; - $resolvedOrgNumber = $verifiedCompany['organization_number']; - - // Cache in session for future requests - $this->context->cookie->two_company_name = $resolvedCompany; - $this->context->cookie->two_company_id = $resolvedOrgNumber; - $this->context->cookie->two_company_country = $countryIso; - $this->context->cookie->setExpire(time() + Twopayment::COOKIE_EXPIRY_ONE_HOUR); - - PrestaShopLogger::addLog( - 'TwoPayment: ✓ Company VERIFIED from address org number - ' . - $existingOrgNumber . ' => ' . $resolvedCompany . ' (cached in session)', - 1 - ); - - return [ - 'company' => $resolvedCompany, - 'companyid' => $resolvedOrgNumber - ]; - } else { - // Org number couldn't be verified - might be invalid or Two API issue - PrestaShopLogger::addLog( - 'TwoPayment: Org number from address could not be verified: ' . $existingOrgNumber . - ' in ' . $countryIso . ' - user will need to search manually', - 2 - ); - } - } - - // FALLBACK: Address has company name but no verifiable org number - // User will need to use company search to select their company - if (!empty($address->company)) { PrestaShopLogger::addLog( - 'TwoPayment: Address has company name but no org number found in fields - ' . - 'company: "' . $address->company . '" in ' . $countryIso, + 'TwoPayment: ✓ Company VERIFIED from address org number - ' . + $existingOrgNumber . ' => ' . $resolvedCompany . ' (cached in session)', 1 ); return [ - 'company' => trim($address->company), - 'companyid' => '' // User needs to search and select + 'company' => $resolvedCompany, + 'companyid' => $resolvedOrgNumber ]; + } else { + // Org number couldn't be verified - might be invalid or Two API issue + PrestaShopLogger::addLog( + 'TwoPayment: Org number from address could not be verified: ' . $existingOrgNumber . + ' in ' . $countryIso . ' - user will need to search manually', + 2 + ); } } + + // FALLBACK: Address has company name but no verifiable org number + // User will need to use company search to select their company + if (!empty($address->company)) { + PrestaShopLogger::addLog( + 'TwoPayment: Address has company name but no org number found in fields - ' . + 'company: "' . $address->company . '" in ' . $countryIso, + 1 + ); + + return [ + 'company' => trim($address->company), + 'companyid' => '' // User needs to search and select + ]; + } } } @@ -584,6 +635,28 @@ private function storeCompanyDataInSession($companyData) if (!empty($companyData['company'])) { $this->context->cookie->two_company_name = $companyData['company']; $this->context->cookie->two_company_id = $companyData['companyid'] ?? ''; + + $addressId = (int) Tools::getValue('id_address_invoice'); + if ($addressId <= 0) { + $addressId = (int) Tools::getValue('id_address_delivery'); + } + if ($addressId <= 0) { + $addressId = (int) $this->context->cart->id_address_invoice; + } + if ($addressId <= 0) { + $addressId = (int) $this->context->cart->id_address_delivery; + } + + if ($addressId > 0) { + $selectedAddress = new Address($addressId); + if (Validate::isLoadedObject($selectedAddress)) { + $countryIso = Country::getIsoById($selectedAddress->id_country); + if ($countryIso && is_string($countryIso)) { + $this->context->cookie->two_company_country = strtoupper($countryIso); + } + $this->context->cookie->two_company_address_id = (string) $addressId; + } + } // Set cookie expiration (1 hour) $this->context->cookie->setExpire(time() + Twopayment::COOKIE_EXPIRY_ONE_HOUR); diff --git a/controllers/front/payment.php b/controllers/front/payment.php index 887a326..ce051dd 100644 --- a/controllers/front/payment.php +++ b/controllers/front/payment.php @@ -17,64 +17,53 @@ public function __construct() public function postProcess() { parent::postProcess(); - // Guard: Require company details when placing order with Two - - //We check if the cart exists; if it doesn’t, we get it from the context - if (isset($cart) && !empty($cart)) { - $address = new Address($cart->id_address_invoice); - } else { - $address = new Address(Context::getContext()->cart->id_address_invoice); - } - - $companyName = isset($address->company) ? trim($address->company) : ''; - $companyId = ''; - // Prefer companyid if present; fallback for ES to dni - if (!empty($address->companyid)) { - $companyId = trim($address->companyid); - } else { - $iso = Country::getIsoById($address->id_country); - if ($iso === 'ES' && !empty($address->dni)) { - $companyId = trim($address->dni); - } - } - // Fallback to cookie values saved during company selection (handles GB and others) - if (Tools::isEmpty($companyName) && isset($this->context->cookie->two_company_name)) { - $companyName = trim($this->context->cookie->two_company_name); - } - if (Tools::isEmpty($companyId) && isset($this->context->cookie->two_company_id)) { - $companyId = trim($this->context->cookie->two_company_id); - } - - if (Tools::isEmpty($companyName) || Tools::isEmpty($companyId)) { - $msg = $this->module->l('To pay with Two, please select your company so we can verify your business and offer invoice terms.'); - $this->errors[] = $msg; - $this->redirectWithNotifications('index.php?controller=order'); - } - - $cart = $this->context->cart; - $currency = new Currency($cart->id_currency); - - // Enhanced cart validation with detailed logging if (!Validate::isLoadedObject($cart)) { - PrestaShopLogger::addLog('TwoPayment: Invalid cart object in payment controller', 2); - Tools::redirect('index.php?controller=order'); + $this->failCheckout( + '', + 'TwoPayment: Invalid cart object in payment controller', + 2 + ); + return; } + $currency = new Currency($cart->id_currency); + if ($cart->id_customer == 0 || $cart->id_address_delivery == 0 || $cart->id_address_invoice == 0) { - PrestaShopLogger::addLog('TwoPayment: Incomplete cart data - Customer: ' . $cart->id_customer . ', Delivery: ' . $cart->id_address_delivery . ', Invoice: ' . $cart->id_address_invoice, 2); - Tools::redirect('index.php?controller=order'); + $this->failCheckout( + '', + 'TwoPayment: Incomplete cart data - Customer: ' . $cart->id_customer . ', Delivery: ' . $cart->id_address_delivery . ', Invoice: ' . $cart->id_address_invoice, + 2 + ); + return; } if (!$this->module->active) { - PrestaShopLogger::addLog('TwoPayment: Payment attempt on inactive module', 2); - Tools::redirect('index.php?controller=order'); + $this->failCheckout( + '', + 'TwoPayment: Payment attempt on inactive module', + 2 + ); + return; } // Validate currency if (!Validate::isLoadedObject($currency)) { - PrestaShopLogger::addLog('TwoPayment: Invalid currency for cart ' . $cart->id, 2); - Tools::redirect('index.php?controller=order'); + $this->failCheckout( + '', + 'TwoPayment: Invalid currency for cart ' . $cart->id, + 2 + ); + return; + } + + if (!$this->module->isCartCurrencySupportedByTwo($cart)) { + $this->failCheckout( + $this->module->l('This payment method is not available for your selected currency.'), + 'TwoPayment: Unsupported currency ' . (int)$cart->id_currency . ' for cart ' . $cart->id, + 2 + ); + return; } $authorized = false; @@ -85,103 +74,139 @@ public function postProcess() } } if (!$authorized) { - $message = $this->module->l('This payment method is not available.'); - $this->errors[] = $message; - $this->redirectWithNotifications('index.php?controller=order'); + $this->failCheckout( + $this->module->l('This payment method is not available.') + ); + return; } $customer = new Customer($cart->id_customer); if (!Validate::isLoadedObject($customer)) { - $message = $this->module->l('Customer is not valid.'); - $this->errors[] = $message; - $this->redirectWithNotifications('index.php?controller=order'); + $this->failCheckout( + $this->module->l('Customer is not valid.') + ); + return; } - // Validate order intent approval if enabled (server-side security layer) - if (Configuration::get('PS_TWO_ENABLE_ORDER_INTENT')) { - $orderIntentApproved = isset($this->context->cookie->two_order_intent_approved) - ? $this->context->cookie->two_order_intent_approved === '1' - : null; - - $orderIntentTimestamp = isset($this->context->cookie->two_order_intent_timestamp) - ? (int)$this->context->cookie->two_order_intent_timestamp - : 0; - - // Check if order intent was checked - if ($orderIntentApproved === null) { - // Order intent was never checked - log but allow (may be disabled or skipped) - PrestaShopLogger::addLog( - 'TwoPayment: Order placed without order intent check (may be disabled or skipped) - Cart ID: ' . $cart->id, - 2 - ); - } elseif ($orderIntentApproved === false) { - // Order intent was checked and DECLINED - BLOCK ORDER - PrestaShopLogger::addLog( - 'TwoPayment: Order BLOCKED - order intent was declined. Cart ID: ' . $cart->id . ', Customer ID: ' . $customer->id, - 3 - ); - - $message = $this->module->l('Your order could not be approved by Two payment. Please choose another payment method or contact support.'); - $this->errors[] = $message; - $this->redirectWithNotifications('index.php?controller=order'); - return; // Stop execution - prevent order creation - } elseif ($orderIntentTimestamp > 0) { - // Check if result is recent (within configured expiry time) - $age = time() - $orderIntentTimestamp; - if ($age > $this->module::ORDER_INTENT_EXPIRY_SECONDS) { - PrestaShopLogger::addLog( - 'TwoPayment: Order intent result expired (age: ' . $age . ' seconds). Requiring re-check. Cart ID: ' . $cart->id, - 2 - ); - - // Block order - require fresh order intent check - $message = $this->module->l('Your payment approval has expired. Please refresh the page and try again.'); - $this->errors[] = $message; - $this->redirectWithNotifications('index.php?controller=order'); - return; // Stop execution - prevent order creation - } - } + // Validate payment form token before any provider request. + $submittedToken = trim((string) Tools::getValue('token')); + if (Tools::isEmpty($submittedToken) || !hash_equals((string) Tools::getToken(false), $submittedToken)) { + $this->failCheckout( + $this->module->l('Your payment approval has expired. Please refresh the page and try again.'), + 'TwoPayment: Payment submit blocked - invalid or missing checkout token for cart ' . $cart->id, + 3 + ); + return; } - // CRITICAL: Create PrestaShop order FIRST to generate order ID for Two's callback URLs - // If Two rejects (non-201), we'll DELETE the order entirely (no phantom orders) - $initial_status = Configuration::get('PS_TWO_OS_AWAITING_VERIFICATION'); - if (!$initial_status) { - // Fallback to mapped state if custom state doesn't exist (for existing installations) - $initial_status = Configuration::get('PS_TWO_OS_AWAITING_VERIFICATION_MAP'); - if (!$initial_status) { - // Final fallback to preparation state - $initial_status = Configuration::get('PS_OS_PREPARATION'); - } + $address = new Address((int) $cart->id_address_invoice); + if (!Validate::isLoadedObject($address)) { + $this->failCheckout( + '', + 'TwoPayment: Invalid invoice address for cart ' . $cart->id . ' while preparing payment', + 3 + ); + return; + } + + // Guard: Require company details when placing order with Two. + // Use shared module resolver so checkout and order payload logic stay consistent. + $companyData = $this->module->getTwoCheckoutCompanyData($address); + $companyName = isset($companyData['company_name']) ? trim((string) $companyData['company_name']) : ''; + $companyId = isset($companyData['organization_number']) ? trim((string) $companyData['organization_number']) : ''; + if (Tools::isEmpty($companyName) || Tools::isEmpty($companyId)) { + $this->failCheckout( + $this->module->l('To pay with Two, please select your company so we can verify your business and offer invoice terms.') + ); + return; + } + + // Keep attempt table bounded without adding cron requirements. + $this->module->maybeCleanupStaleTwoCheckoutAttempts(); + + // Authoritative server-side order intent check at payment submit. + $orderIntentResult = $this->module->checkTwoOrderIntentApprovalAtPayment($cart, $customer, $currency, $address); + $frontendIntentTelemetry = isset($this->context->cookie->two_order_intent_approved) + ? $this->context->cookie->two_order_intent_approved === '1' + : null; + if ($frontendIntentTelemetry !== null && $frontendIntentTelemetry !== (bool)$orderIntentResult['approved']) { + PrestaShopLogger::addLog( + 'TwoPayment: Frontend order intent telemetry differs from backend authoritative result for cart ' . + $cart->id . '. Frontend=' . ($frontendIntentTelemetry ? 'approved' : 'declined') . + ', Backend=' . ((bool)$orderIntentResult['approved'] ? 'approved' : 'declined'), + 2 + ); + } + + if (!(bool)$orderIntentResult['approved']) { + $failureMessage = (isset($orderIntentResult['message']) && !Tools::isEmpty($orderIntentResult['message'])) + ? (string)$orderIntentResult['message'] + : $this->module->l('Your order could not be approved by Two payment. Please choose another payment method or contact support.'); + $this->failCheckout( + $failureMessage, + 'TwoPayment: Order blocked by authoritative backend order intent check for cart ' . + $cart->id . '. Status=' . (isset($orderIntentResult['status']) ? $orderIntentResult['status'] : 'unknown') . + ', HTTP=' . (isset($orderIntentResult['http_status']) ? (int)$orderIntentResult['http_status'] : 0), + 3 + ); + return; } - - $this->module->validateOrder($cart->id, $initial_status, $cart->getOrderTotal(true, Cart::BOTH), $this->module->displayName, null, array(), (int) $currency->id, false, $customer->secure_key); - $created_order_id = $this->module->currentOrder; - - PrestaShopLogger::addLog('TwoPayment: PrestaShop order created (ID: ' . $created_order_id . '), now calling Two API to create Two order', 1); - // Build Two order payload with PrestaShop order ID for callbacks - $paymentdata = $this->module->getTwoNewOrderData($created_order_id, $cart); + // Provider-first flow: create Two order first, then create PrestaShop order only after verified callback + $attempt_token = $this->module->generateTwoCheckoutAttemptToken($cart->id, $customer->id); + $merchant_order_id = $this->module->buildTwoMerchantOrderId($attempt_token, $cart->id); + + $merchant_urls = [ + 'merchant_confirmation_url' => $this->context->link->getModuleLink($this->module->name, 'confirmation', ['attempt_token' => $attempt_token, 'key' => $customer->secure_key], true), + 'merchant_cancel_order_url' => $this->context->link->getModuleLink($this->module->name, 'cancel', ['attempt_token' => $attempt_token, 'key' => $customer->secure_key], true), + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '' + ]; + + try { + $paymentdata = $this->module->getTwoNewOrderData($merchant_order_id, $cart, $merchant_urls); + $cart_snapshot_hash = $this->module->calculateTwoCheckoutSnapshotHash($cart, $paymentdata); + $idempotency_key = $this->module->buildTwoOrderCreateIdempotencyKey($cart, $cart_snapshot_hash); + } catch (Exception $e) { + $this->failCheckout( + $this->module->l('Unable to process your order with Two payment. Please review your cart and try again.'), + 'TwoPayment: Failed building order payload for cart ' . $cart->id . ' - ' . $e->getMessage(), + 3 + ); + return; + } // Call Two API to create order - $response = $this->module->setTwoPaymentRequest('/v1/order', $paymentdata, 'POST'); + $response = $this->module->setTwoPaymentRequest( + '/v1/order', + $paymentdata, + 'POST', + ['X-Idempotency-Key: ' . $idempotency_key] + ); // Extract HTTP status code from enhanced response structure $http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; - PrestaShopLogger::addLog('TwoPayment: Two API response - HTTP ' . $http_status . ' - Body: ' . json_encode($response), ($http_status === Twopayment::HTTP_STATUS_CREATED ? 1 : 3)); + $response_summary = $this->module->buildTwoApiResponseLogSummary($response); + PrestaShopLogger::addLog( + 'TwoPayment: Two API response summary - ' . json_encode($response_summary), + ($http_status === Twopayment::HTTP_STATUS_CREATED ? 1 : 3) + ); // CRITICAL CHECK: Only proceed if Two returned 201 Created - // Any other status = order creation failed, delete PrestaShop order + // Any other status = order creation failed, and no local order should exist if ($http_status !== Twopayment::HTTP_STATUS_CREATED) { - // Two rejected the order - DELETE PrestaShop order completely (no phantom orders) - PrestaShopLogger::addLog('TwoPayment: Two API did not return 201 (got ' . $http_status . ') - DELETING PrestaShop order ' . $created_order_id, 3); - - // Delete order from database - $this->module->deleteOrder($created_order_id); - - // Restore cart so customer can try again - $this->module->restoreDuplicateCart($created_order_id, $customer->id); + PrestaShopLogger::addLog( + 'TwoPayment: Two API did not return 201 (got ' . $http_status . ') - no local order created for cart ' . $cart->id . ', attempt ' . $attempt_token, + 3 + ); + PrestaShopLogger::addLog( + 'TwoPayment: Provider order lifecycle - create_failed for attempt ' . $attempt_token . + ', HTTP ' . $http_status, + 2 + ); // Determine user-friendly error message based on response $message = $this->module->l('Unable to process your order with Two payment.'); @@ -209,23 +234,28 @@ public function postProcess() if (isset($response['id']) && $response['id']) { // Extract invoice ID from response if available $invoice_id = isset($response['invoice_details']['id']) ? $response['invoice_details']['id'] : null; + $resolved_terms = $this->module->resolveTwoPaymentTermsFromOrderResponse( + $response, + (string)$this->module->getSelectedPaymentTerm(), + Configuration::get('PS_TWO_PAYMENT_TERM_TYPE') + ); // Log invoice ID extraction for debugging if ($invoice_id) { PrestaShopLogger::addLog( - 'TwoPayment: Invoice ID extracted from order creation - Order ' . $this->module->currentOrder . ', Invoice ID: ' . $invoice_id, + 'TwoPayment: Invoice ID extracted from order creation - attempt ' . $attempt_token . ', Invoice ID: ' . $invoice_id, 1, null, - 'Order', - $this->module->currentOrder + 'Cart', + $cart->id ); } else { PrestaShopLogger::addLog( - 'TwoPayment: No invoice ID in order creation response - Order ' . $this->module->currentOrder, + 'TwoPayment: No invoice ID in order creation response - attempt ' . $attempt_token, 2, null, - 'Order', - $this->module->currentOrder + 'Cart', + $cart->id ); } @@ -234,13 +264,35 @@ public function postProcess() '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_payment_term_type' => Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'), // Term type (STANDARD or EOM) + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], 'two_invoice_url' => $response['invoice_url'], 'two_invoice_id' => $invoice_id, ); - $this->module->setTwoOrderPaymentData($this->module->currentOrder, $payment_data); + $attempt_data = array_merge($payment_data, array( + 'id_cart' => (int)$cart->id, + 'id_customer' => (int)$customer->id, + 'id_order' => null, + 'customer_secure_key' => $customer->secure_key, + 'merchant_order_id' => $merchant_order_id, + 'cart_snapshot_hash' => $cart_snapshot_hash, + 'order_create_idempotency_key' => $idempotency_key, + 'status' => 'CREATED', + )); + + if (!$this->module->setTwoCheckoutAttempt($attempt_token, $attempt_data)) { + PrestaShopLogger::addLog( + 'TwoPayment: Failed to persist checkout attempt ' . $attempt_token . ' for cart ' . $cart->id, + 3 + ); + if (isset($response['id']) && $response['id']) { + // Best effort cleanup when local attempt persistence fails. + $this->module->cancelTwoOrderBestEffort((string)$response['id'], 'attempt_persist_failed'); + } + $this->errors[] = $this->module->l('Temporary checkout issue. Please try again.'); + $this->redirectWithNotifications('index.php?controller=order'); + } // Fraud Verification Skip (Must be enabled by Two on request) // If merchant has set fraud_verification_skip=true in paymentdata, handle accordingly @@ -249,43 +301,46 @@ public function postProcess() if ($fraudVerificationSkip) { // Merchant wants to skip fraud verification - validate that Two verified the order $orderState = isset($response['state']) ? strtoupper($response['state']) : ''; + $validSkipStates = array('VERIFIED', 'CONFIRMED', 'FULFILLED'); - if ($orderState === 'VERIFIED') { - // Order is verified - skip payment_url redirect and go directly to confirmation + if (in_array($orderState, $validSkipStates, true)) { + // Order is verified - skip payment_url redirect and go directly to local confirmation callback PrestaShopLogger::addLog( - 'TwoPayment: Fraud verification skipped for order ' . $this->module->currentOrder . ' - Order state is VERIFIED, proceeding to confirmation', + 'TwoPayment: Fraud verification skipped for attempt ' . $attempt_token . ' - Order state is ' . $orderState . ', proceeding to confirmation', 1, null, - 'Order', - $this->module->currentOrder + 'Cart', + $cart->id ); - - // Update order status to "Two: Verified - Ready for Fulfillment" - // This is the correct status for orders that are verified and awaiting fulfillment - $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT'); - if (!$verified_status) { - // Fallback to mapped state if custom state doesn't exist - $verified_status = Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT_MAP'); - if (!$verified_status) { - // Final fallback to payment accepted - $verified_status = Configuration::get('PS_OS_PAYMENT'); - } - } - $this->module->changeOrderStatus($this->module->currentOrder, $verified_status); - - // Redirect to order confirmation page - Tools::redirect('index.php?controller=order-confirmation&id_cart=' . $cart->id . '&id_module=' . $this->module->id . '&id_order=' . $this->module->currentOrder . '&key=' . $customer->secure_key); + + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'REDIRECTED', array( + 'two_order_state' => $response['state'], + 'two_order_status' => $response['status'], + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], + 'two_invoice_url' => isset($response['invoice_url']) ? $response['invoice_url'] : '', + 'two_invoice_id' => $invoice_id, + )); + + Tools::redirect($this->context->link->getModuleLink($this->module->name, 'confirmation', ['attempt_token' => $attempt_token, 'key' => $customer->secure_key], true)); } else { // Order is NOT verified but merchant requested to skip verification - this is an error - $this->module->restoreDuplicateCart($this->module->currentOrder, $customer->id); - $this->module->changeOrderStatus($this->module->currentOrder, Configuration::get('PS_TWO_OS_PAYMENT_ERROR_MAP')); - + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED', array( + 'two_order_state' => isset($response['state']) ? $response['state'] : '', + 'two_order_status' => isset($response['status']) ? $response['status'] : '', + )); + + // Best effort provider cleanup + if (isset($response['id']) && $response['id']) { + $this->module->cancelTwoOrderBestEffort((string)$response['id'], 'fraud_skip_state_invalid'); + } + PrestaShopLogger::addLog( - 'TwoPayment: Fraud verification skip requested for order ' . $this->module->currentOrder . ' but order state is "' . $orderState . '" (expected VERIFIED). Blocking checkout.', + 'TwoPayment: Fraud verification skip requested for attempt ' . $attempt_token . ' but order state is "' . $orderState . '" (expected one of ' . implode(', ', $validSkipStates) . '). Blocking checkout.', 3, null, - 'Order', - $this->module->currentOrder + 'Cart', + $cart->id ); // Generic error message - don't expose fraud verification skip details to customer @@ -295,15 +350,62 @@ public function postProcess() } } else { // Standard flow - redirect to Two's payment_url for verification + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'REDIRECTED', array( + 'two_order_state' => isset($response['state']) ? $response['state'] : '', + 'two_order_status' => isset($response['status']) ? $response['status'] : '', + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], + 'two_invoice_url' => isset($response['invoice_url']) ? $response['invoice_url'] : '', + 'two_invoice_id' => $invoice_id, + )); + + if (!isset($response['payment_url']) || Tools::isEmpty($response['payment_url'])) { + $this->module->updateTwoCheckoutAttemptStatus($attempt_token, 'FAILED'); + if (isset($response['id']) && !Tools::isEmpty($response['id'])) { + $cancelled = $this->module->cancelTwoOrderBestEffort((string)$response['id'], 'missing_payment_url'); + PrestaShopLogger::addLog( + 'TwoPayment: Provider order lifecycle - created_without_redirect for attempt ' . $attempt_token . + ', Two order ' . $response['id'] . + ', cleanup=' . ($cancelled ? 'cancelled' : 'cancel_failed'), + $cancelled ? 2 : 3 + ); + } + $this->errors[] = $this->module->l('Unable to redirect to payment provider. Please try again.'); + $this->redirectWithNotifications('index.php?controller=order'); + } + Tools::redirect($response['payment_url']); } } else { - $this->module->restoreDuplicateCart($this->module->currentOrder, $customer->id); - $this->module->changeOrderStatus($this->module->currentOrder, Configuration::get('PS_TWO_OS_PAYMENT_ERROR_MAP')); + PrestaShopLogger::addLog( + 'TwoPayment: Two API created response without id for cart ' . $cart->id . ', attempt ' . $attempt_token, + 3 + ); $message = $this->module->l('Something went wrong please contact store owner.'); $this->errors[] = $message; $this->redirectWithNotifications('index.php?controller=order'); } } + /** + * Redirect checkout flow back to order page with optional user-facing error. + * + * @param string $message + * @param string $logMessage + * @param int $severity + * @return void + */ + private function failCheckout($message = '', $logMessage = '', $severity = 2) + { + if (!Tools::isEmpty($logMessage)) { + PrestaShopLogger::addLog($logMessage, (int)$severity); + } + if (!Tools::isEmpty($message)) { + $this->errors[] = $message; + $this->redirectWithNotifications('index.php?controller=order'); + return; + } + Tools::redirect('index.php?controller=order'); + } + } diff --git a/override/classes/form/CustomerAddressFormatter.php b/override/classes/form/CustomerAddressFormatter.php index df220f6..7a9e65a 100644 --- a/override/classes/form/CustomerAddressFormatter.php +++ b/override/classes/form/CustomerAddressFormatter.php @@ -7,248 +7,142 @@ class CustomerAddressFormatter extends CustomerAddressFormatterCore { - - private $country; private $translator; - private $availableCountries; private $definition; public function __construct(Country $country, $translator, array $availableCountries) { - $this->country = $country; + parent::__construct($country, $translator, $availableCountries); $this->translator = $translator; - $this->availableCountries = $availableCountries; $this->definition = Address::$definition['fields']; } public function setCountry(Country $country) { - $this->country = $country; + parent::setCountry($country); return $this; } public function getCountry() { - return $this->country; + return parent::getCountry(); } public function getFormat() { - $fields = AddressFormat::getOrderedAddressFields($this->country->id, true, true); - $required = array_flip(AddressFormat::getFieldsRequired()); - if (Module::isInstalled('twopayment') && Module::isEnabled('twopayment')) { - $format = [ - 'back' => (new FormField()) - ->setName('back') - ->setType('hidden'), - 'token' => (new FormField()) - ->setName('token') - ->setType('hidden'), - 'alias' => (new FormField()) - ->setName('alias') - ->setLabel( - $this->getFieldLabel('alias') - ), - ]; + $format = parent::getFormat(); + if (!is_array($format) || !Module::isInstalled('twopayment') || !Module::isEnabled('twopayment')) { + return $format; + } - // Conditionally insert account_type when merchant wants account type selection - if ((int) Configuration::get('PS_TWO_USE_ACCOUNT_TYPE')) { - $format = [ - 'back' => (new FormField()) - ->setName('back') - ->setType('hidden'), - 'token' => (new FormField()) - ->setName('token') - ->setType('hidden'), - 'account_type' => (new FormField()) - ->setName('account_type') - ->setType('select') - ->setRequired(true) - ->addAvailableValue('personal', $this->getFieldLabel('personal_type')) - ->addAvailableValue('business', $this->getFieldLabel('business_type')) - ->setLabel($this->getFieldLabel('account_type')), - 'alias' => (new FormField()) - ->setName('alias') - ->setLabel( - $this->getFieldLabel('alias') - ), - ]; - } + $format = $this->moveFieldBefore($format, 'id_country', 'company'); - //insert new fileds - conditionally based on admin settings - $inserted = array(); - - // Department field - only add if enabled in admin settings - if (Configuration::get('PS_TWO_ENABLE_DEPARTMENT')) { - $inserted[] = 'department'; - } - - // Project field - only add if enabled in admin settings - if (Configuration::get('PS_TWO_ENABLE_PROJECT')) { - $inserted[] = 'project'; - } - - // Note: companyid field is handled via hidden JavaScript field only - // No database persistence - form-first approach like old tillit.js - - if (!empty($inserted)) { - array_splice($fields, 3, 0, $inserted); + $useAccountType = (int) Configuration::get('PS_TWO_USE_ACCOUNT_TYPE') === 1; + + if ($useAccountType && !isset($format['account_type'])) { + $accountTypeField = (new FormField()) + ->setName('account_type') + ->setType('select') + ->setRequired(true) + ->addAvailableValue('personal', $this->getFieldLabel('personal_type')) + ->addAvailableValue('business', $this->getFieldLabel('business_type')) + ->setLabel($this->getFieldLabel('account_type')); + $this->applyFieldDefinitionMetadata($accountTypeField, 'account_type'); + $format = $this->insertFieldAfter($format, 'token', 'account_type', $accountTypeField); + } + + if (isset($format['company']) && $format['company'] instanceof FormField) { + $format['company']->addAvailableValue('placeholder', $this->translator->trans('Search your company name', [], 'Shop.Forms.Labels')); + if ($useAccountType) { + $format['company']->addAvailableValue('data-conditional-field', 'business'); + $format['company']->addAvailableValue('data-conditional-required', 'business'); + $format['company']->addAvailableValue('data-initial-state', 'hidden'); } + } - //move country fileds - $out = array_splice($fields, array_search('Country:name', $fields), 1); - array_splice($fields, 2, 0, $out); + if (isset($format['phone']) && $format['phone'] instanceof FormField) { + $format['phone']->setType('tel'); + $format['phone']->setRequired(true); + } - foreach ($fields as $field) { - $formField = new FormField(); - $formField->setName($field); - $fieldParts = explode(':', $field, 2); + if ((int) Configuration::get('PS_TWO_ENABLE_DEPARTMENT') === 1 && !isset($format['department'])) { + $departmentField = (new FormField()) + ->setName('department') + ->setType('text') + ->setLabel($this->getFieldLabel('department')); + $this->applyFieldDefinitionMetadata($departmentField, 'department'); + $format = $this->insertFieldAfter($format, 'company', 'department', $departmentField); + } - if ($field === 'address2') { - $formField->setType('number'); - } + if ((int) Configuration::get('PS_TWO_ENABLE_PROJECT') === 1 && !isset($format['project'])) { + $projectField = (new FormField()) + ->setName('project') + ->setType('text') + ->setLabel($this->getFieldLabel('project')); + $this->applyFieldDefinitionMetadata($projectField, 'project'); + $format = $this->insertFieldAfter($format, 'department', 'project', $projectField); + } - // CRITICAL: Company field handling for Two payment functionality - if ($field === 'company') { - $formField->addAvailableValue('placeholder', $this->translator->trans('Search your company name', [], 'Shop.Forms.Labels')); - if ((int) Configuration::get('PS_TWO_USE_ACCOUNT_TYPE')) { - // Make company field conditionally visible and required via JavaScript when using account type - $formField->addAvailableValue('data-conditional-field', 'business'); - $formField->addAvailableValue('data-conditional-required', 'business'); - // Initially hidden - will be shown when business account is selected - $formField->addAvailableValue('data-initial-state', 'hidden'); - } - } - if (count($fieldParts) === 1) { - if ($field === 'postcode') { - if ($this->country->need_zip_code) { - $formField->setRequired(true); - } - } elseif ($field === 'phone') { - $formField->setType('tel'); - $formField->setRequired(true); - } elseif ($field === 'dni' && null !== $this->country) { - if ($this->country->need_identification_number) { - $formField->setRequired(true); - } - } - } elseif (count($fieldParts) === 2) { - list($entity, $entityField) = $fieldParts; - $formField->setType('select'); - $formField->setName('id_' . Tools::strtolower($entity)); - if ($entity === 'Country') { - $formField->setType('countrySelect'); - $formField->setValue($this->country->id); - foreach ($this->availableCountries as $country) { - $formField->addAvailableValue( - $country['id_country'], - $country[$entityField] - ); - } - } elseif ($entity === 'State') { - if ($this->country->contains_states) { - $states = State::getStatesByIdCountry($this->country->id, true); - foreach ($states as $state) { - $formField->addAvailableValue( - $state['id_state'], - $state[$entityField] - ); - } - $formField->setRequired(true); - } - } - } - $formField->setLabel($this->getFieldLabel($field)); - if (!$formField->isRequired()) { - $formField->setRequired( - array_key_exists($field, $required) - ); - } - $format[$formField->getName()] = $formField; - } - } else { - $format = [ - 'back' => (new FormField()) - ->setName('back') - ->setType('hidden'), - 'token' => (new FormField()) - ->setName('token') - ->setType('hidden'), - 'alias' => (new FormField()) - ->setName('alias') - ->setLabel( - $this->getFieldLabel('alias') - ), - ]; - foreach ($fields as $field) { - $formField = new FormField(); - $formField->setName($field); - $fieldParts = explode(':', $field, 2); - if ($field === 'address2') { - $formField->setRequired(true); - $formField->setType('number'); - } - if (count($fieldParts) === 1) { - if ($field === 'postcode') { - if ($this->country->need_zip_code) { - $formField->setRequired(true); - } - } elseif ($field === 'phone') { - $formField->setType('tel'); - } elseif ($field === 'dni' && null !== $this->country) { - if ($this->country->need_identification_number) { - $formField->setRequired(true); - } - } - } elseif (count($fieldParts) === 2) { - list($entity, $entityField) = $fieldParts; - $formField->setType('select'); - $formField->setName('id_' . Tools::strtolower($entity)); - if ($entity === 'Country') { - $formField->setType('countrySelect'); - $formField->setValue($this->country->id); - foreach ($this->availableCountries as $country) { - $formField->addAvailableValue( - $country['id_country'], - $country[$entityField] - ); - } - } elseif ($entity === 'State') { - if ($this->country->contains_states) { - $states = State::getStatesByIdCountry($this->country->id, true); - foreach ($states as $state) { - $formField->addAvailableValue( - $state['id_state'], - $state[$entityField] - ); - } - $formField->setRequired(true); - } - } - } - $formField->setLabel($this->getFieldLabel($field)); - if (!$formField->isRequired()) { - $formField->setRequired( - array_key_exists($field, $required) - ); - } - $format[$formField->getName()] = $formField; + return $format; + } + + private function insertFieldAfter(array $format, $afterKey, $newKey, FormField $field) + { + $result = array(); + $inserted = false; + + foreach ($format as $key => $value) { + $result[$key] = $value; + if ($key === $afterKey) { + $result[$newKey] = $field; + $inserted = true; } } - $additionalAddressFormFields = Hook::exec('additionalCustomerAddressFields', ['fields' => &$format], null, true); - if (is_array($additionalAddressFormFields)) { - foreach ($additionalAddressFormFields as $moduleName => $additionnalFormFields) { - if (!is_array($additionnalFormFields)) { - continue; - } - foreach ($additionnalFormFields as $formField) { - $formField->moduleName = $moduleName; - $format[$moduleName . '_' . $formField->getName()] = $formField; - } + + if (!$inserted) { + $result[$newKey] = $field; + } + + return $result; + } + + private function moveFieldBefore(array $format, $fieldKey, $beforeKey) + { + if (!array_key_exists($fieldKey, $format) || !array_key_exists($beforeKey, $format) || $fieldKey === $beforeKey) { + return $format; + } + + $keys = array_keys($format); + $fieldIndex = array_search($fieldKey, $keys, true); + $beforeIndex = array_search($beforeKey, $keys, true); + if ($fieldIndex === false || $beforeIndex === false || $fieldIndex < $beforeIndex) { + return $format; + } + + $field = $format[$fieldKey]; + unset($format[$fieldKey]); + + $result = array(); + foreach ($format as $key => $value) { + if ($key === $beforeKey) { + $result[$fieldKey] = $field; } + $result[$key] = $value; + } + + return $result; + } + + private function applyFieldDefinitionMetadata(FormField $field, $fieldName) + { + if (!empty($this->definition[$fieldName]['validate'])) { + $field->addConstraint($this->definition[$fieldName]['validate']); + } + + if (!empty($this->definition[$fieldName]['size'])) { + $field->setMaxLength($this->definition[$fieldName]['size']); } - return $this->addConstraints($this->addMaxLength($format)); } private function addConstraints(array $format) diff --git a/package-release.sh b/package-release.sh new file mode 100755 index 0000000..1f0bcb0 --- /dev/null +++ b/package-release.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +# Two Payment Module - Release Packaging Script +# Creates a clean ZIP file for distribution + +set -e + +MODULE_NAME="twopayment" +PARENT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MODULE_DIR="${PARENT_DIR}/${MODULE_NAME}" +TEMP_DIR="/tmp/${MODULE_NAME}-package-$$" + +# Check we're in the right directory +if [ ! -f "${MODULE_DIR}/twopayment.php" ]; then + echo "ERROR: twopayment.php not found. Are you in the correct directory?" + exit 1 +fi + +# Derive module version directly from twopayment.php to avoid manual mismatch +VERSION=$(grep -E '\$this->version[[:space:]]*=' "${MODULE_DIR}/twopayment.php" | head -1 | sed -E "s/.*=[[:space:]]*['\"]([^'\"]+)['\"].*/\1/") +if [ -z "${VERSION}" ]; then + echo "ERROR: Unable to derive module version from twopayment.php" + exit 1 +fi +PACKAGE_NAME="${MODULE_NAME}-v${VERSION}.zip" + +echo "==========================================" +echo "Two Payment Module - Release Packaging" +echo "Version: ${VERSION}" +echo "==========================================" +echo "" + +# Verify config.xml is aligned with derived module version +XML_VERSION=$(grep -E "" "${MODULE_DIR}/config.xml" | head -1) + +if [ -z "$XML_VERSION" ]; then + echo "WARNING: Version mismatch detected!" + echo "Please verify version is ${VERSION} in config.xml" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +echo "✓ Version check passed" +echo "" + +# Create temporary directory +echo "Creating temporary package directory..." +mkdir -p "${TEMP_DIR}/${MODULE_NAME}" +cd "${MODULE_DIR}" + +# Copy files, excluding unnecessary ones +echo "Copying module files (excluding dev files)..." + +# Use rsync if available, otherwise use find + cp +if command -v rsync &> /dev/null; then + rsync -av \ + --exclude='.git' \ + --exclude='.gitignore' \ + --exclude='.DS_Store' \ + --exclude='__MACOSX' \ + --exclude='.cursor' \ + --exclude='.ai' \ + --exclude='CLAUDE.md' \ + --exclude='PRODUCTION_REVIEW.md' \ + --exclude='package-release.sh' \ + --exclude='*.log' \ + --exclude='node_modules' \ + --exclude='.idea' \ + --exclude='*.swp' \ + --exclude='*.swo' \ + --exclude='*~' \ + --exclude='.env' \ + --exclude='.env.local' \ + --exclude='composer.lock' \ + --exclude='package.json' \ + --exclude='package-lock.json' \ + ./ "${TEMP_DIR}/${MODULE_NAME}/" +else + # Fallback: use find and cp + find . -type f \ + ! -path './.git/*' \ + ! -path './.cursor/*' \ + ! -path './.ai/*' \ + ! -name '.gitignore' \ + ! -name '.DS_Store' \ + ! -name 'CLAUDE.md' \ + ! -name 'PRODUCTION_REVIEW.md' \ + ! -name 'package-release.sh' \ + ! -name '*.log' \ + ! -path '*/node_modules/*' \ + ! -path '*/.idea/*' \ + ! -name '*.swp' \ + ! -name '*.swo' \ + ! -name '*~' \ + ! -name '.env*' \ + ! -name 'composer.lock' \ + ! -name 'package.json' \ + ! -name 'package-lock.json' \ + -exec cp --parents {} "${TEMP_DIR}/${MODULE_NAME}/" \; +fi + +echo "✓ Files copied" +echo "" + +# Remove any .DS_Store files that might have been copied +find "${TEMP_DIR}" -name ".DS_Store" -delete 2>/dev/null || true + +# Create ZIP file +echo "Creating ZIP archive..." +cd "${TEMP_DIR}" +zip -r "${PARENT_DIR}/${PACKAGE_NAME}" "${MODULE_NAME}" -q +cd - > /dev/null + +# Clean up temp directory +rm -rf "${TEMP_DIR}" + +# Get file size +FILE_SIZE=$(du -h "${PARENT_DIR}/${PACKAGE_NAME}" | cut -f1) + +echo "✓ Package created successfully!" +echo "" +echo "==========================================" +echo "Package Details:" +echo "==========================================" +echo "Filename: ${PACKAGE_NAME}" +echo "Location: ${PARENT_DIR}/${PACKAGE_NAME}" +echo "Size: ${FILE_SIZE}" +echo "" +echo "Package contents verified:" +echo " ✓ Module files" +echo " ✓ Controllers" +echo " ✓ Views (CSS, JS, templates)" +echo " ✓ Translations" +echo " ✓ Upgrade scripts" +echo " ✓ Vendor dependencies" +echo " ✓ README.md" +echo " ✓ CHANGELOG.md" +echo "" +echo "Excluded from package:" +echo " ✗ .git directory" +echo " ✗ .cursor directory (IDE config)" +echo " ✗ .ai directory (AI context files)" +echo " ✗ CLAUDE.md (AI context file)" +echo " ✗ PRODUCTION_REVIEW.md (internal docs)" +echo " ✗ Development files (.DS_Store, logs, etc.)" +echo "" +echo "Ready for distribution! 🚀" +echo "==========================================" diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8f01962 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/tests/CustomerAddressFormatterOverrideSpec.php b/tests/CustomerAddressFormatterOverrideSpec.php new file mode 100644 index 0000000..37b00ea --- /dev/null +++ b/tests/CustomerAddressFormatterOverrideSpec.php @@ -0,0 +1,178 @@ +country = $country; + $this->translator = $translator; + $this->availableCountries = $availableCountries; + } + + public function getFormat() + { + $isSpanishCountry = isset($this->country->id) && (int) $this->country->id === 6; + + return [ + 'alias' => (new FormField()) + ->setName('alias') + ->setType('text') + ->setLabel($this->getFieldLabel('alias')), + 'company' => (new FormField())->setName('company')->setType('text'), + 'id_country' => (new FormField())->setName('id_country')->setType('countrySelect'), + 'phone' => (new FormField())->setName('phone')->setType('text'), + 'dni' => (new FormField())->setName('dni')->setType('text')->setRequired($isSpanishCountry), + ]; + } + + public function setCountry(Country $country) + { + $this->country = $country; + + return $this; + } + + public function getCountry() + { + return $this->country; + } + + private function getFieldLabel(string $field): string + { + return $this->translator->trans('Alias', [], 'Shop.Forms.Labels'); + } + } +} + +final class CustomerAddressFormatterOverrideSpec +{ + public static function runAll(): void + { + self::testOverrideConstructorInitializesCoreTranslatorState(); + self::testCountryFieldIsPositionedBeforeCompany(); + self::testDniFieldIsPreservedByOverride(); + self::testCountrySwitchKeepsCoreFormatterCountryInSync(); + } + + private static function testOverrideConstructorInitializesCoreTranslatorState(): void + { + $overridePath = dirname(__DIR__) . '/override/classes/form/CustomerAddressFormatter.php'; + if (!class_exists('CustomerAddressFormatter', false)) { + require_once $overridePath; + } + + $translator = new class { + public function trans($message, array $params = [], $domain = null): string + { + return (string) $message; + } + }; + + $formatter = new CustomerAddressFormatter(new Country(), $translator, []); + + try { + $format = $formatter->getFormat(); + } catch (Throwable $exception) { + throw new RuntimeException( + 'CustomerAddressFormatter override must keep core formatter translator initialized. Failure: ' + . $exception->getMessage() + ); + } + + TinyAssert::true(isset($format['alias']) && $format['alias'] instanceof FormField, 'Expected alias field in formatter output'); + TinyAssert::same('Alias', $format['alias']->getLabel(), 'Expected alias label translation from core formatter'); + } + + private static function testCountryFieldIsPositionedBeforeCompany(): void + { + $overridePath = dirname(__DIR__) . '/override/classes/form/CustomerAddressFormatter.php'; + if (!class_exists('CustomerAddressFormatter', false)) { + require_once $overridePath; + } + + $translator = new class { + public function trans($message, array $params = [], $domain = null): string + { + return (string) $message; + } + }; + + $formatter = new CustomerAddressFormatter(new Country(), $translator, []); + $format = $formatter->getFormat(); + $keys = array_keys($format); + $countryPosition = array_search('id_country', $keys, true); + $companyPosition = array_search('company', $keys, true); + + TinyAssert::true($countryPosition !== false, 'Expected id_country field in formatter output'); + TinyAssert::true($companyPosition !== false, 'Expected company field in formatter output'); + TinyAssert::true( + $countryPosition < $companyPosition, + 'Expected country selector to be positioned before company field in checkout addresses' + ); + } + + private static function testDniFieldIsPreservedByOverride(): void + { + $overridePath = dirname(__DIR__) . '/override/classes/form/CustomerAddressFormatter.php'; + if (!class_exists('CustomerAddressFormatter', false)) { + require_once $overridePath; + } + + $translator = new class { + public function trans($message, array $params = [], $domain = null): string + { + return (string) $message; + } + }; + + $formatter = new CustomerAddressFormatter(self::makeCountry(6), $translator, []); + $format = $formatter->getFormat(); + + TinyAssert::true(isset($format['dni']) && $format['dni'] instanceof FormField, 'Expected dni field in formatter output'); + TinyAssert::true($format['dni']->isRequired(), 'Expected dni field required flag to be preserved'); + } + + private static function testCountrySwitchKeepsCoreFormatterCountryInSync(): void + { + $overridePath = dirname(__DIR__) . '/override/classes/form/CustomerAddressFormatter.php'; + if (!class_exists('CustomerAddressFormatter', false)) { + require_once $overridePath; + } + + $translator = new class { + public function trans($message, array $params = [], $domain = null): string + { + return (string) $message; + } + }; + + $ukCountry = self::makeCountry(17); + $esCountry = self::makeCountry(6); + + $formatter = new CustomerAddressFormatter($ukCountry, $translator, []); + $formatter->setCountry($esCountry); + $format = $formatter->getFormat(); + + TinyAssert::same(6, (int) $formatter->getCountry()->id, 'Expected formatter country to switch to Spain'); + TinyAssert::true(isset($format['dni']) && $format['dni']->isRequired(), 'Expected dni to be required after country switch to Spain'); + } + + private static function makeCountry(int $id): Country + { + return new class($id) extends Country { + public $id; + + public function __construct(int $id) + { + $this->id = $id; + } + }; + } +} diff --git a/tests/OrderBuilderTest.php b/tests/OrderBuilderTest.php new file mode 100644 index 0000000..23282e4 --- /dev/null +++ b/tests/OrderBuilderTest.php @@ -0,0 +1,3108 @@ + 'TV', + 'net_amount' => '100.00', + 'tax_amount' => '15.00', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'discount_amount' => '0.00', + ]]; + + self::assertFalse($module->validateTwoLineItems($lineItems)); + } + + public function testGetTwoTaxSubtotalsNormalizesTaxRateToTwoDecimals(): void + { + $module = new TwopaymentTestHarness(); + + $lineItems = [ + ['tax_rate' => '0.205', 'net_amount' => '100.00', 'tax_amount' => '20.50'], + ['tax_rate' => '0.205', 'net_amount' => '50.00', 'tax_amount' => '10.25'], + ['tax_rate' => '0.21', 'net_amount' => '200.00', 'tax_amount' => '42.00'], + ]; + + $subtotals = $module->getTwoTaxSubtotals($lineItems); + + self::assertCount(1, $subtotals); + self::assertSame('0.21', $subtotals[0]['tax_rate']); + self::assertSame('350.00', $subtotals[0]['taxable_amount']); + self::assertSame('72.75', $subtotals[0]['tax_amount']); + } + + public function testGetTwoProductItemsUsesAppliedTaxRateWhenConfiguredRateDiffers(): void + { + $module = new TwopaymentTestHarness(); + + $cart = new Cart(10); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[10] = [[ + 'id_product' => 501, + 'link_rewrite' => 'smart-tv', + 'name' => 'Smart TV', + 'description_short' => 'Test description', + 'manufacturer_name' => 'LG', + 'ean13' => '1234567890123', + 'upc' => '012345678905', + 'total' => 100.00, + 'total_wt' => 120.50, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + + StubStore::$productCategories[501] = [['name' => 'Electronics']]; + StubStore::$images[501] = ['id_image' => 9001]; + + $items = $module->getTwoProductItems($cart); + + self::assertCount(1, $items); + self::assertSame('0.205', $items[0]['tax_rate']); + self::assertSame('20.50', $items[0]['tax_amount']); + self::assertSame('120.50', $items[0]['gross_amount']); + } + + public function testGetTwoProductItemsSplitsEcotaxIntoServiceLine(): void + { + $module = new TwopaymentTestHarness(); + + $cart = new Cart(11); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[11] = [[ + 'id_product' => 777, + 'link_rewrite' => 'eco-product', + 'name' => 'Eco Product', + 'description_short' => 'Eco friendly', + 'manufacturer_name' => 'Green Co', + 'ean13' => '', + 'upc' => '', + 'total' => 110.00, + 'total_wt' => 131.55, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 110.00, + 'reduction' => 0, + 'ecotax' => 10.00, + 'ecotax_tax_rate' => 5.5, + ]]; + + StubStore::$productCategories[777] = [['name' => 'Accessories']]; + StubStore::$images[777] = ['id_image' => 9011]; + + $items = $module->getTwoProductItems($cart); + + self::assertCount(2, $items); + self::assertSame('PHYSICAL', $items[0]['type']); + self::assertSame('100.00', (string)$items[0]['net_amount']); + self::assertSame('121.00', (string)$items[0]['gross_amount']); + self::assertSame('21.00', (string)$items[0]['tax_amount']); + self::assertSame('0.21', (string)$items[0]['tax_rate']); + + self::assertSame('SERVICE', $items[1]['type']); + self::assertSame('10.00', (string)$items[1]['net_amount']); + self::assertSame('10.55', (string)$items[1]['gross_amount']); + self::assertSame('0.55', (string)$items[1]['tax_amount']); + self::assertSame('0.055', (string)$items[1]['tax_rate']); + } + + public function testGetTwoNewOrderDataSupportsFivePointFivePercentVat(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7001] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Eva', + 'lastname' => 'Martin', + 'secure_key' => 'secure-key-7001', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[7101] = [ + 'id_country' => 33, + 'company' => 'Acme FR SAS', + 'companyid' => 'FR123456789', + 'address1' => '10 Rue de Paris', + 'city' => 'Paris', + 'postcode' => '75001', + 'phone' => '+33100000000', + 'loaded' => true, + ]; + StubStore::$addresses[7102] = StubStore::$addresses[7101]; + StubStore::$countries[33] = 'FR'; + + $cart = new Cart(7001); + $cart->id_customer = 7001; + $cart->id_currency = 978; + $cart->id_address_invoice = 7101; + $cart->id_address_delivery = 7102; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7001] = [[ + 'id_product' => 9301, + 'link_rewrite' => 'reduced-vat-item', + 'name' => 'Reduced VAT item', + 'description_short' => 'Reduced VAT test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 105.50, + 'cart_quantity' => 1, + 'rate' => 5.5, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9301] = [['name' => 'Books']]; + StubStore::$images[9301] = ['id_image' => 9301]; + StubStore::$cartTotals[7001] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 105.50, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 5.5, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7001', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertSame('105.50', $payload['gross_amount']); + self::assertSame('100.00', $payload['net_amount']); + self::assertSame('5.50', $payload['tax_amount']); + self::assertSame('0.055', $payload['line_items'][0]['tax_rate']); + } + + public function testGetTwoNewOrderDataIncludesGiftWrappingLine(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7002] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-7002', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7201] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7202] = StubStore::$addresses[7201]; + + $cart = new Cart(7002); + $cart->id_customer = 7002; + $cart->id_currency = 978; + $cart->id_address_invoice = 7201; + $cart->id_address_delivery = 7202; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7002] = [[ + 'id_product' => 9302, + 'link_rewrite' => 'gift-product', + 'name' => 'Gift Product', + 'description_short' => 'Gift product test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9302] = [['name' => 'Gifts']]; + StubStore::$images[9302] = ['id_image' => 9302]; + StubStore::$cartTotals[7002] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 123.42, + Cart::ONLY_WRAPPING => 2.42, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 102.00, + Cart::ONLY_WRAPPING => 2.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7002', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertSame('123.42', $payload['gross_amount']); + self::assertSame('102.00', $payload['net_amount']); + self::assertSame('21.42', $payload['tax_amount']); + + $hasWrappingLine = false; + foreach ($payload['line_items'] as $lineItem) { + if ((string)($lineItem['name'] ?? '') === 'Gift wrapping') { + $hasWrappingLine = true; + self::assertSame('2.42', (string)$lineItem['gross_amount']); + self::assertSame('2.00', (string)$lineItem['net_amount']); + self::assertSame('0.42', (string)$lineItem['tax_amount']); + } + } + self::assertTrue($hasWrappingLine); + } + + public function testGetTwoNewOrderDataSnapsGiftWrappingRateToCanonicalContext(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7003] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Lena', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-7003', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7301] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7302] = StubStore::$addresses[7301]; + + $cart = new Cart(7003); + $cart->id_customer = 7003; + $cart->id_currency = 978; + $cart->id_address_invoice = 7301; + $cart->id_address_delivery = 7302; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7003] = [[ + 'id_product' => 9303, + 'link_rewrite' => 'gift-product-canonical', + 'name' => 'Gift Product Canonical', + 'description_short' => 'Gift product test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9303] = [['name' => 'Gifts']]; + StubStore::$images[9303] = ['id_image' => 9303]; + StubStore::$cartTotals[7003] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 123.99, + Cart::ONLY_WRAPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 102.47, + Cart::ONLY_WRAPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7003', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $wrappingLine = null; + foreach ($payload['line_items'] as $lineItem) { + if ((string)($lineItem['name'] ?? '') === 'Gift wrapping') { + $wrappingLine = $lineItem; + break; + } + } + + self::assertNotNull($wrappingLine, 'Expected gift wrapping line'); + self::assertSame('2.99', (string)$wrappingLine['gross_amount']); + self::assertSame('2.47', (string)$wrappingLine['net_amount']); + self::assertSame('0.52', (string)$wrappingLine['tax_amount']); + self::assertSame('0.21', (string)$wrappingLine['tax_rate']); + } + + public function testGetTwoProductItemsSnapsMinorRateDriftToKnownProductContexts(): void + { + $module = new TwopaymentTestHarness(); + + $cart = new Cart(12); + $cart->id_lang = 1; + $cart->id_carrier = 0; + + StubStore::$cartProducts[12] = [ + [ + 'id_product' => 8801, + 'link_rewrite' => 'anchor-product', + 'name' => 'Anchor Product', + 'description_short' => 'Anchor', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ], + [ + 'id_product' => 8802, + 'link_rewrite' => 'drift-product', + 'name' => 'Drift Product', + 'description_short' => 'Drift', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 2.47, + 'total_wt' => 2.99, + 'cart_quantity' => 1, + // Intentionally missing rate field to simulate amount-derived drift. + 'price' => 2.47, + 'reduction' => 0, + ], + ]; + + StubStore::$productCategories[8801] = [['name' => 'General']]; + StubStore::$productCategories[8802] = [['name' => 'General']]; + StubStore::$images[8801] = ['id_image' => 8801]; + StubStore::$images[8802] = ['id_image' => 8802]; + + $items = $module->getTwoProductItems($cart); + + self::assertCount(2, $items); + self::assertSame('0.21', (string)$items[0]['tax_rate']); + self::assertSame('0.21', (string)$items[1]['tax_rate']); + } + + public function testGetTwoProductItemsSpanishFallbackDefaultsToTwentyOnePercentWithoutKnownContext(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7401] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7402] = StubStore::$addresses[7401]; + + $cart = new Cart(14); + $cart->id_lang = 1; + $cart->id_carrier = 0; + $cart->id_address_invoice = 7401; + $cart->id_address_delivery = 7402; + + StubStore::$cartProducts[14] = [[ + 'id_product' => 8811, + 'link_rewrite' => 'es-fallback-product', + 'name' => 'ES fallback product', + 'description_short' => 'Fallback', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 2.47, + 'total_wt' => 2.99, + 'cart_quantity' => 1, + // Intentionally no rate field to force amount-derived unresolved context. + 'price' => 2.47, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8811] = [['name' => 'General']]; + StubStore::$images[8811] = ['id_image' => 8811]; + + $items = $module->getTwoProductItems($cart); + + self::assertCount(1, $items); + self::assertSame('0.21', (string)$items[0]['tax_rate']); + } + + public function testGetTwoNewOrderDataOmitsTopLevelTaxRate(): void + { + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[301] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Juan', + 'lastname' => 'Gonzalez', + 'secure_key' => 'secure-key', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[401] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[402] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(55); + $cart->id_customer = 301; + $cart->id_currency = 978; + $cart->id_address_invoice = 401; + $cart->id_address_delivery = 402; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[55] = [['id_product' => 501, 'cart_quantity' => 1]]; + StubStore::$cartTotals[55] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-55', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertArrayNotHasKey('tax_rate', $payload); + self::assertArrayHasKey('tax_subtotals', $payload); + self::assertSame('100.00', $payload['net_amount']); + self::assertSame('20.50', $payload['tax_amount']); + self::assertSame('120.50', $payload['gross_amount']); + } + + public function testGetTwoNewOrderDataOmitsTaxSubtotalsWhenDisabled(): void + { + StubStore::$configuration['PS_TWO_ENABLE_TAX_SUBTOTALS'] = 0; + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[401] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Juan', + 'lastname' => 'Gonzalez', + 'secure_key' => 'secure-key', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[601] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[602] = StubStore::$addresses[601]; + + $cart = new Cart(155); + $cart->id_customer = 401; + $cart->id_currency = 978; + $cart->id_address_invoice = 601; + $cart->id_address_delivery = 602; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[155] = [['id_product' => 601, 'cart_quantity' => 1]]; + StubStore::$cartTotals[155] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-155', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertArrayNotHasKey('tax_subtotals', $payload); + self::assertArrayNotHasKey('tax_rate', $payload); + } + + public function testGetTwoIntentOrderDataOmitsTopLevelTaxRateAndOmitsTaxSubtotalsWhenDisabled(): void + { + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + }; + + StubStore::$customers[402] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-intent', + 'loaded' => true, + ]; + StubStore::$currencies[840] = ['iso_code' => 'USD', 'loaded' => true]; + StubStore::$addresses[603] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(156); + $cart->id_customer = 402; + $cart->id_currency = 840; + $cart->id_address_invoice = 603; + $cart->id_address_delivery = 603; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[156] = [['id_product' => 602, 'cart_quantity' => 1]]; + StubStore::$cartTotals[156] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $customer = new Customer(402); + $currency = new Currency(840); + $address = new Address(603); + + $payloadWithSubtotals = $module->getTwoIntentOrderData($cart, $customer, $currency, $address); + self::assertArrayNotHasKey('tax_rate', $payloadWithSubtotals); + self::assertArrayHasKey('tax_subtotals', $payloadWithSubtotals); + self::assertArrayHasKey('billing_address', $payloadWithSubtotals); + self::assertArrayHasKey('shipping_address', $payloadWithSubtotals); + self::assertSame('ES', $payloadWithSubtotals['billing_address']['country']); + self::assertSame('ES', $payloadWithSubtotals['shipping_address']['country']); + + StubStore::$configuration['PS_TWO_ENABLE_TAX_SUBTOTALS'] = 0; + $payloadWithoutSubtotals = $module->getTwoIntentOrderData($cart, $customer, $currency, $address); + self::assertArrayNotHasKey('tax_rate', $payloadWithoutSubtotals); + self::assertArrayNotHasKey('tax_subtotals', $payloadWithoutSubtotals); + } + + public function testGetTwoRequestHeadersSkipAuthForOrderIntent(): void + { + $module = new TwopaymentTestHarness(); + + $orderIntentHeaders = $module->getTwoRequestHeaders( + '/v1/order_intent', + ['Authorization: Bearer should-not-leak', 'X-API-Key: should-not-leak'] + ); + $createOrderHeaders = $module->getTwoRequestHeaders('/v1/order'); + + foreach ($orderIntentHeaders as $header) { + self::assertFalse(strpos($header, 'X-API-Key:') === 0); + self::assertFalse(strpos($header, 'Authorization:') === 0); + self::assertFalse(strpos($header, 'Proxy-Authorization:') === 0); + } + + $createOrderHasApiKey = false; + foreach ($createOrderHeaders as $header) { + if (strpos($header, 'X-API-Key:') === 0) { + $createOrderHasApiKey = true; + break; + } + } + self::assertTrue($createOrderHasApiKey); + } + + public function testGetTwoNewOrderDataThrowsWhenLineItemsFailFormulaValidation(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoProductItems($cart) + { + return [[ + 'name' => 'Broken line', + 'net_amount' => '100.00', + 'tax_amount' => '10.00', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'discount_amount' => '0.00', + ]]; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[302] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Maria', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-2', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[501] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[502] = StubStore::$addresses[501]; + + $cart = new Cart(56); + $cart->id_customer = 302; + $cart->id_currency = 978; + $cart->id_address_invoice = 501; + $cart->id_address_delivery = 502; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[56] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[56] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid line item formulas'); + + $module->getTwoNewOrderData('merchant-attempt-56', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + } + + public function testCheckTwoOrderIntentApprovalAtPaymentDeclinesEvenWhenFrontendCookieSaysApproved(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 200, + 'approved' => false, + ]; + } + }; + + $module->context->cookie->two_order_intent_approved = '1'; + $module->context->cookie->two_order_intent_timestamp = (string) time(); + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + self::assertFalse($result['approved']); + self::assertSame('declined', $result['status']); + } + + public function testCheckTwoOrderIntentApprovalAtPaymentAllowsApprovedResponse(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 200, + 'approved' => true, + 'message' => 'ok', + ]; + } + }; + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + self::assertTrue($result['approved']); + self::assertSame('approved', $result['status']); + } + + public function testCheckTwoOrderIntentApprovalAtPaymentHandlesProviderNetworkFailure(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 0, + 'error' => 'Connection error', + 'error_message' => 'Unable to connect', + ]; + } + }; + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + self::assertFalse($result['approved']); + self::assertSame('provider_unavailable', $result['status']); + } + + public function testCheckTwoOrderIntentApprovalAtPaymentBlocksOnStrictReconciliationDrift(): void + { + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Product', + 'gross_amount' => '121.00', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '21.00', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + public bool $providerCalled = false; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + $this->providerCalled = true; + return [ + 'http_status' => 200, + 'approved' => true, + ]; + } + }; + + StubStore::$customers[781] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-781', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[1781] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(781); + $cart->id_customer = 781; + $cart->id_currency = 978; + $cart->id_address_invoice = 1781; + $cart->id_address_delivery = 1781; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[781] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[781] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 121.20, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $result = $module->checkTwoOrderIntentApprovalAtPayment($cart, new Customer(781), new Currency(978), new Address(1781)); + + self::assertFalse($result['approved']); + self::assertSame('reconciliation_mismatch', $result['status']); + self::assertFalse($module->providerCalled); + } + + public function testCreateTwoLocalOrderAfterProviderVerificationRecoversExistingOrderOnRace(): void + { + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$customers[882] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-882', + 'loaded' => true, + ]; + + $cart = new Cart(882); + $cart->id_customer = 882; + $cart->id_currency = 978; + + $module = new class extends TwopaymentTestHarness { + public function validateOrder( + $id_cart, + $id_order_state, + $amount_paid, + $payment_method = 'Unknown', + $message = null, + $extra_vars = [], + $currency_special = null, + $dont_touch_amount = false, + $secure_key = false, + ?Shop $shop = null, + ?string $order_reference = null + ) { + throw new Exception('Cart cannot be loaded or an order has already been placed using this cart'); + } + + public function getTwoOrderIdByCart($id_cart) + { + return 445; + } + }; + + $result = $module->createTwoLocalOrderAfterProviderVerification( + $cart, + new Customer(882), + 1, + 121.00 + ); + + self::assertTrue($result['success']); + self::assertSame(445, (int) $result['id_order']); + self::assertTrue($result['recovered_existing']); + } + + public function testCreateTwoLocalOrderAfterProviderVerificationFailsWhenNoRecoverableOrderExists(): void + { + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$customers[883] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-883', + 'loaded' => true, + ]; + + $cart = new Cart(883); + $cart->id_customer = 883; + $cart->id_currency = 978; + + $module = new class extends TwopaymentTestHarness { + public function validateOrder( + $id_cart, + $id_order_state, + $amount_paid, + $payment_method = 'Unknown', + $message = null, + $extra_vars = [], + $currency_special = null, + $dont_touch_amount = false, + $secure_key = false, + ?Shop $shop = null, + ?string $order_reference = null + ) { + throw new Exception('cart exception'); + } + + public function getTwoOrderIdByCart($id_cart) + { + return 0; + } + }; + + $result = $module->createTwoLocalOrderAfterProviderVerification( + $cart, + new Customer(883), + 1, + 121.00 + ); + + self::assertFalse($result['success']); + self::assertSame(0, (int) $result['id_order']); + self::assertFalse($result['recovered_existing']); + } + + public function testCancelTwoOrderBestEffortReturnsTrueOnSuccessAndFalseOnFailure(): void + { + $successModule = new class extends TwopaymentTestHarness { + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return ['http_status' => 200]; + } + }; + self::assertTrue($successModule->cancelTwoOrderBestEffort('two-success', 'test')); + + $failureModule = new class extends TwopaymentTestHarness { + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return ['http_status' => 500]; + } + }; + self::assertFalse($failureModule->cancelTwoOrderBestEffort('two-failure', 'test')); + } + + public function testExtractTwoProviderGrossAmountForValidationSupportsRootAndNestedPayloads(): void + { + $module = new TwopaymentTestHarness(); + + self::assertSame(121.10, $module->extractTwoProviderGrossAmountForValidation(['gross_amount' => '121.10'])); + self::assertSame(1518.10, $module->extractTwoProviderGrossAmountForValidation(['data' => ['gross_amount' => '1518.10']])); + self::assertSame(null, $module->extractTwoProviderGrossAmountForValidation(['gross_amount' => ''])); + } + + public function testSnapshotHashIgnoresTaxRateChangesBeyondTwoDecimals(): void + { + $module = new TwopaymentTestHarness(); + + $cart = new stdClass(); + $cart->id = 77; + $cart->id_customer = 1; + $cart->id_currency = 978; + $cart->id_address_invoice = 1; + $cart->id_address_delivery = 1; + $cart->id_carrier = 0; + + $basePayload = [ + 'currency' => 'EUR', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'tax_amount' => '20.50', + 'discount_amount' => '0.00', + 'line_items' => [[ + 'type' => 'PHYSICAL', + 'quantity' => 1, + 'unit_price' => '100.00', + 'net_amount' => '100.00', + 'tax_amount' => '20.50', + 'gross_amount' => '120.50', + 'discount_amount' => '0.00', + 'tax_rate' => '0.205', + ]], + 'tax_subtotals' => [[ + 'tax_rate' => '0.205', + 'taxable_amount' => '100.00', + 'tax_amount' => '20.50', + ]], + ]; + + $changedPayload = $basePayload; + $changedPayload['line_items'][0]['tax_rate'] = '0.206'; + $changedPayload['tax_subtotals'][0]['tax_rate'] = '0.206'; + + $hashA = $module->calculateTwoCheckoutSnapshotHash($cart, $basePayload); + $hashB = $module->calculateTwoCheckoutSnapshotHash($cart, $changedPayload); + + self::assertSame($hashA, $hashB); + } + + public function testIsTwoAttemptCallbackAuthorizedWithMatchingKey(): void + { + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 77, + 'customer_secure_key' => 'secure-key-77', + ]; + + self::assertTrue($module->isTwoAttemptCallbackAuthorized($attempt, 'secure-key-77')); + } + + public function testIsTwoAttemptCallbackAuthorizedFallsBackToContextCustomerKeyWhenRequestKeyMissing(): void + { + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 99, + 'customer_secure_key' => 'secure-key-99', + ]; + + self::assertTrue($module->isTwoAttemptCallbackAuthorized($attempt, '', 99, 'secure-key-99')); + } + + public function testIsTwoAttemptCallbackAuthorizedRejectsMismatchedKeys(): void + { + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 42, + 'customer_secure_key' => 'secure-key-42', + ]; + + self::assertFalse($module->isTwoAttemptCallbackAuthorized($attempt, 'invalid-key', 42, 'secure-key-42')); + self::assertFalse($module->isTwoAttemptCallbackAuthorized($attempt, '', 41, 'secure-key-42')); + } + + public function testGetTwoBuyerPortalUrlUsesEnvironmentSpecificBuyerDomains(): void + { + $module = new TwopaymentTestHarness(); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'production'); + self::assertSame('https://buyer.two.inc/login', $module->getTwoBuyerPortalUrl()); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'development'); + self::assertSame('https://buyer.sandbox.two.inc/login', $module->getTwoBuyerPortalUrl()); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'staging'); + self::assertSame('https://buyer.sandbox.two.inc/login', $module->getTwoBuyerPortalUrl()); + } + + public function testResolveTwoAttemptOrderIdForCancellationPrefersAttemptOrderId(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoOrderIdByCart($id_cart) + { + return 777; + } + }; + + $attempt = [ + 'id_order' => 321, + 'id_cart' => 123, + ]; + + self::assertSame(321, $module->resolveTwoAttemptOrderIdForCancellation($attempt)); + } + + public function testResolveTwoAttemptOrderIdForCancellationFallsBackToCartOrderId(): void + { + $module = new class extends TwopaymentTestHarness { + public function getTwoOrderIdByCart($id_cart) + { + return ((int)$id_cart === 123) ? 654 : 0; + } + }; + + $attempt = [ + 'id_order' => 0, + 'id_cart' => 123, + ]; + + self::assertSame(654, $module->resolveTwoAttemptOrderIdForCancellation($attempt)); + } + + public function testShouldBlockTwoAttemptConfirmationByStatusOnlyForCancelled(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->shouldBlockTwoAttemptConfirmationByStatus('CANCELLED')); + self::assertTrue($module->shouldBlockTwoAttemptConfirmationByStatus('cancelled')); + self::assertFalse($module->shouldBlockTwoAttemptConfirmationByStatus('CREATED')); + self::assertFalse($module->shouldBlockTwoAttemptConfirmationByStatus('CONFIRMED')); + } + + public function testIsTwoAttemptStatusTerminalMatchesCancelledGuard(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->isTwoAttemptStatusTerminal('CANCELLED')); + self::assertFalse($module->isTwoAttemptStatusTerminal('CONFIRMED')); + } + + public function testGetTwoCancelledOrderStatusIdUsesConfiguredFallbackChain(): void + { + $module = new TwopaymentTestHarness(); + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + Configuration::updateValue('PS_TWO_OS_CANCELLED_MAP', 902); + Configuration::updateValue('PS_OS_CANCELED', 903); + self::assertSame(901, $module->getTwoCancelledOrderStatusId()); + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 0); + self::assertSame(902, $module->getTwoCancelledOrderStatusId()); + + Configuration::updateValue('PS_TWO_OS_CANCELLED_MAP', 0); + self::assertSame(903, $module->getTwoCancelledOrderStatusId()); + } + + public function testSyncLocalOrderStatusFromTwoStateCancelsOnlyWhenProviderCancelled(): void + { + $module = new class extends TwopaymentTestHarness { + public $calls = []; + + public function changeOrderStatus($id_order, $id_order_status) + { + $this->calls[] = [(int)$id_order, (int)$id_order_status]; + return true; + } + }; + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + self::assertTrue($module->syncLocalOrderStatusFromTwoState(55, 'CANCELLED')); + self::assertCount(1, $module->calls); + self::assertSame([55, 901], $module->calls[0]); + + self::assertFalse($module->syncLocalOrderStatusFromTwoState(56, 'CONFIRMED')); + self::assertCount(1, $module->calls); + } + + public function testIsTwoOrderCancelledResponseRequires2xxAndCancelledState(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->isTwoOrderCancelledResponse([ + 'http_status' => 200, + 'state' => 'CANCELLED', + ])); + + self::assertFalse($module->isTwoOrderCancelledResponse([ + 'http_status' => 200, + 'state' => 'CONFIRMED', + ])); + + self::assertFalse($module->isTwoOrderCancelledResponse([ + 'http_status' => 500, + 'state' => 'CANCELLED', + ])); + + self::assertFalse($module->isTwoOrderCancelledResponse([], 200)); + } + + public function testShouldBlockTwoFulfillmentByTwoStateOnlyForCancelled(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->shouldBlockTwoFulfillmentByTwoState('CANCELLED')); + self::assertTrue($module->shouldBlockTwoFulfillmentByTwoState('cancelled')); + self::assertFalse($module->shouldBlockTwoFulfillmentByTwoState('CONFIRMED')); + self::assertFalse($module->shouldBlockTwoFulfillmentByTwoState('')); + } + + public function testShouldBlockTwoStatusTransitionByCancelledStateCoversVerifiedAndFulfillment(): void + { + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT', 901); + Configuration::updateValue('PS_TWO_OS_FULFILLED_MAP', json_encode([4])); + Configuration::updateValue('PS_OS_SHIPPING', 4); + + self::assertTrue($module->shouldBlockTwoStatusTransitionByCancelledState(901)); + self::assertTrue($module->shouldBlockTwoStatusTransitionByCancelledState(4)); + self::assertFalse($module->shouldBlockTwoStatusTransitionByCancelledState(99)); + } + + public function testIsTwoOrderFulfillableStateRequiresConfirmed(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->isTwoOrderFulfillableState('CONFIRMED')); + self::assertTrue($module->isTwoOrderFulfillableState('confirmed')); + self::assertFalse($module->isTwoOrderFulfillableState('CANCELLED')); + self::assertFalse($module->isTwoOrderFulfillableState('VERIFIED')); + } + + public function testAddTwoBackOfficeWarningAppendsUniqueWarning(): void + { + $module = new TwopaymentTestHarness(); + $module->context->controller = (object) ['warnings' => []]; + + self::assertTrue($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + self::assertCount(1, $module->context->controller->warnings); + + self::assertTrue($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + self::assertCount(1, $module->context->controller->warnings); + } + + public function testAddTwoBackOfficeWarningReturnsFalseWhenNoController(): void + { + $module = new TwopaymentTestHarness(); + $module->context->controller = null; + + self::assertFalse($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + } + + public function testApplyTwoCancelledOrderStateProfileToStatusObjectUsesConfiguredCancelledState(): void + { + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + + $status = (object) [ + 'id' => 4, + 'shipped' => 1, + 'logable' => 1, + ]; + + self::assertTrue($module->applyTwoCancelledOrderStateProfileToStatusObject($status, 1)); + self::assertSame(901, (int)$status->id); + self::assertSame(0, (int)$status->shipped); + self::assertSame(0, (int)$status->logable); + } + + public function testForceTwoCancelledOrderHistoryStateBeforeInsertRewritesPendingStatus(): void + { + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + + $history = (object) [ + 'id_order_state' => 4, + 'logable' => 1, + ]; + + $order = new class { + public $loaded = true; + public $id_lang = 1; + public $current_state = 4; + public $valid = true; + public $updated = false; + + public function update() + { + $this->updated = true; + return true; + } + }; + + self::assertTrue($module->forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, 'two-order-1', 'provider', 'CANCELLED')); + self::assertSame(901, (int)$history->id_order_state); + self::assertSame(901, (int)$order->current_state); + self::assertSame(false, (bool)$order->valid); + self::assertTrue($order->updated); + } + + public function testGetTwoCheckoutCompanyDataUsesAddressVatNumberForAnyCountry(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[801] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $address = new Address(801); + $data = $module->getTwoCheckoutCompanyData($address); + + self::assertSame('Acme UK Ltd', $data['company_name']); + self::assertSame('123456789', $data['organization_number']); + self::assertSame('GB', $data['country_iso']); + } + + public function testGetTwoCheckoutCompanyDataPrefersCurrentAddressOrgNumberOverSessionCompany(): void + { + $module = new TwopaymentTestHarness(); + + $module->context->cookie->two_company_name = 'CHEESE AND BEES LTD'; + $module->context->cookie->two_company_id = 'SC806781'; + $module->context->cookie->two_company_country = 'GB'; + $module->context->cookie->two_company_address_id = '28'; + + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[29] = [ + 'id_country' => 34, + 'company' => 'Queso y Abejas S.L.', + 'vat_number' => 'ESB12345678', + 'loaded' => true, + ]; + + $address = new Address(29); + $data = $module->getTwoCheckoutCompanyData($address); + + self::assertSame('Queso y Abejas S.L.', $data['company_name']); + self::assertSame('B12345678', $data['organization_number']); + self::assertSame('ES', $data['country_iso']); + } + + public function testGetTwoCheckoutCompanyDataUsesValidatedCookieFallback(): void + { + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme ES S.L.'; + $module->context->cookie->two_company_id = 'B12345678'; + $module->context->cookie->two_company_country = 'ES'; + + StubStore::$addresses[802] = [ + 'id_country' => 34, + 'company' => '', + 'loaded' => true, + ]; + + $address = new Address(802); + $data = $module->getTwoCheckoutCompanyData($address); + + self::assertSame('Acme ES S.L.', $data['company_name']); + self::assertSame('B12345678', $data['organization_number']); + self::assertSame('ES', $data['country_iso']); + } + + public function testGetTwoCheckoutCompanyDataClearsStaleCookieOnCountryMismatch(): void + { + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme Norge'; + $module->context->cookie->two_company_id = 'NO123'; + $module->context->cookie->two_company_country = 'NO'; + + StubStore::$addresses[803] = [ + 'id_country' => 34, + 'company' => '', + 'loaded' => true, + ]; + + $address = new Address(803); + $data = $module->getTwoCheckoutCompanyData($address); + + self::assertSame('', $data['company_name']); + self::assertSame('', $data['organization_number']); + self::assertSame('ES', $data['country_iso']); + self::assertFalse(isset($module->context->cookie->two_company_name)); + self::assertFalse(isset($module->context->cookie->two_company_id)); + self::assertFalse(isset($module->context->cookie->two_company_country)); + } + + public function testGetTwoCheckoutCompanyDataIgnoresStaleCookieWhenAddressCompanyChangesSameCountry(): void + { + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme ES S.L.'; + $module->context->cookie->two_company_id = 'B12345678'; + $module->context->cookie->two_company_country = 'ES'; + $module->context->cookie->two_company_address_id = '999'; + + StubStore::$addresses[804] = [ + 'id_country' => 34, + 'company' => 'Beta Industrial S.L.', + 'loaded' => true, + ]; + + $address = new Address(804); + $data = $module->getTwoCheckoutCompanyData($address); + + self::assertSame('Beta Industrial S.L.', $data['company_name']); + self::assertSame('', $data['organization_number']); + self::assertSame('ES', $data['country_iso']); + } + + public function testSaveGeneralFormDoesNotChangeSslVerificationFlag(): void + { + $module = new class extends TwopaymentTestHarness { + public function saveGeneralForTest(): void + { + $this->saveTwoGeneralFormValues(); + } + }; + + Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', 1); + Tools::setTestValue('PS_TWO_DISABLE_SSL_VERIFY', 0); + Tools::setTestValue('PS_TWO_ENVIRONMENT', 'development'); + Tools::setTestValue('PS_TWO_TITLE_1', 'Two title'); + Tools::setTestValue('PS_TWO_SUB_TITLE_1', 'Two subtitle'); + Tools::setTestValue('PS_TWO_MERCHANT_SHORT_NAME', 'merchant'); + Tools::setTestValue('PS_TWO_MERCHANT_API_KEY', 'api-key'); + + $module->saveGeneralForTest(); + + self::assertSame(1, (int) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY')); + } + + public function testSaveOtherFormUpdatesSslVerificationFlag(): void + { + $module = new class extends TwopaymentTestHarness { + public function saveOtherForTest(): void + { + $this->saveTwoOtherFormValues(); + } + }; + + Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', 0); + Configuration::updateValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 1); + Tools::setTestValue('PS_TWO_DISABLE_SSL_VERIFY', 1); + Tools::setTestValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 0); + + $module->saveOtherForTest(); + + self::assertSame(1, (int) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY')); + self::assertSame(0, (int) Configuration::get('PS_TWO_ENABLE_TAX_SUBTOTALS')); + } + + public function testOtherSettingsFormDoesNotExposeOrderIntentToggle(): void + { + $module = new class extends TwopaymentTestHarness { + public function getOtherFormForTest(): array + { + return $this->getTwoOtherForm(); + } + }; + + $form = $module->getOtherFormForTest(); + $inputNames = array_map(function ($field) { + return isset($field['name']) ? (string) $field['name'] : ''; + }, $form['form']['input']); + + self::assertNotContains('PS_TWO_ENABLE_ORDER_INTENT', $inputNames); + self::assertContains('PS_TWO_ENABLE_TAX_SUBTOTALS', $inputNames); + } + + public function testHookActionAdminControllerSetMediaRegistersCssOnModuleConfigPage(): void + { + $module = new TwopaymentTestHarness(); + + $controller = new class { + public $controller_name = 'AdminModules'; + public $php_self = 'module'; + public $styles = []; + + public function registerStylesheet($id, $path, $options = []) + { + $this->styles[] = [ + 'id' => $id, + 'path' => $path, + 'options' => $options, + ]; + } + }; + + $module->context->controller = $controller; + Tools::setTestValue('configure', 'twopayment'); + Tools::setTestValue('controller', 'AdminModules'); + + $module->hookActionAdminControllerSetMedia(); + + self::assertCount(1, $controller->styles); + self::assertSame('module-twopayment-admin-css', $controller->styles[0]['id']); + } + + public function testHookActionAdminControllerSetMediaSkipsUnrelatedAdminPage(): void + { + $module = new TwopaymentTestHarness(); + + $controller = new class { + public $controller_name = 'AdminProducts'; + public $php_self = 'products'; + public $styles = []; + + public function registerStylesheet($id, $path, $options = []) + { + $this->styles[] = [ + 'id' => $id, + 'path' => $path, + 'options' => $options, + ]; + } + }; + + $module->context->controller = $controller; + Tools::setTestValue('configure', 'othermodule'); + Tools::setTestValue('controller', 'AdminProducts'); + + $module->hookActionAdminControllerSetMedia(); + + self::assertCount(0, $controller->styles); + } + + public function testHookPaymentOptionsBlocksWhenAccountTypeMissingInStrictMode(): void + { + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 1); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[901] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $cart = new Cart(501); + $cart->id_address_invoice = 901; + $cart->id_currency = 978; + $module->context->cart = $cart; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 978]]; + + $options = $module->hookPaymentOptions([]); + + self::assertCount(0, $options); + } + + public function testHookPaymentOptionsBlocksNonBusinessWhenAccountTypePresent(): void + { + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 1); + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[902] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'dni' => 'B12345678', + 'account_type' => 'private', + 'loaded' => true, + ]; + + $cart = new Cart(502); + $cart->id_address_invoice = 902; + $cart->id_currency = 978; + $module->context->cart = $cart; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 978]]; + + $options = $module->hookPaymentOptions([]); + + self::assertCount(0, $options); + } + + public function testHookPaymentOptionsAllowsTwoCoveredCurrencies(): void + { + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 0); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[904] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $covered = [ + 578 => 'NOK', + 826 => 'GBP', + 752 => 'SEK', + 840 => 'USD', + 208 => 'DKK', + 978 => 'EUR', + ]; + + foreach ($covered as $idCurrency => $iso) { + StubStore::$currencies[$idCurrency] = ['iso_code' => $iso, 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => $idCurrency]]; + + $cart = new Cart(504 + $idCurrency); + $cart->id_address_invoice = 904; + $cart->id_currency = $idCurrency; + $module->context->cart = $cart; + + $options = $module->hookPaymentOptions([]); + self::assertCount(1, $options, 'Expected covered currency to be allowed: ' . $iso); + } + } + + public function testHookPaymentOptionsBlocksUnsupportedCurrency(): void + { + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 0); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[903] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + StubStore::$currencies[392] = ['iso_code' => 'JPY', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 392]]; + + $cart = new Cart(503); + $cart->id_address_invoice = 903; + $cart->id_currency = 392; + $module->context->cart = $cart; + + $options = $module->hookPaymentOptions([]); + + self::assertCount(0, $options); + } + + public function testMergeTwoPaymentTermFallbackUsesFallbackWhenMissing(): void + { + $module = new TwopaymentTestHarness(); + + $base = [ + 'id_order' => 11, + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + ]; + $fallback = [ + 'two_day_on_invoice' => '45', + 'two_payment_term_type' => 'EOM', + ]; + + $merged = $module->mergeTwoPaymentTermFallback($base, $fallback); + + self::assertSame('45', (string) $merged['two_day_on_invoice']); + self::assertSame('EOM', (string) $merged['two_payment_term_type']); + } + + public function testMergeTwoPaymentTermFallbackKeepsExistingValues(): void + { + $module = new TwopaymentTestHarness(); + + $base = [ + 'id_order' => 12, + 'two_day_on_invoice' => '30', + 'two_payment_term_type' => 'STANDARD', + ]; + $fallback = [ + 'two_day_on_invoice' => '60', + 'two_payment_term_type' => 'EOM', + ]; + + $merged = $module->mergeTwoPaymentTermFallback($base, $fallback); + + self::assertSame('30', (string) $merged['two_day_on_invoice']); + self::assertSame('STANDARD', (string) $merged['two_payment_term_type']); + } + + public function testShouldExposeTwoInvoiceActionsRequiresFulfilledState(): void + { + $module = new TwopaymentTestHarness(); + + self::assertTrue($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'FULFILLED'])); + self::assertFalse($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'VERIFIED'])); + self::assertFalse($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'CONFIRMED'])); + self::assertFalse($module->shouldExposeTwoInvoiceActions(['two_order_state' => ''])); + } + + public function testResolveTwoPaymentTermsFromOrderResponseUsesEndOfMonthAsEom(): void + { + $module = new TwopaymentTestHarness(); + + $response = [ + 'terms' => [ + 'duration_days' => 60, + 'duration_days_calculated_from' => 'END_OF_MONTH', + ], + ]; + + $resolved = $module->resolveTwoPaymentTermsFromOrderResponse($response, '30', 'STANDARD'); + + self::assertSame('60', (string)$resolved['two_day_on_invoice']); + self::assertSame('EOM', (string)$resolved['two_payment_term_type']); + } + + public function testResolveTwoPaymentTermsFromOrderResponseFallsBackToStandardForUnsupportedScheme(): void + { + $module = new TwopaymentTestHarness(); + + $response = [ + 'terms' => [ + 'duration_days' => 45, + 'duration_days_calculated_from' => 'END_OF_WEEK', + ], + ]; + + $resolved = $module->resolveTwoPaymentTermsFromOrderResponse($response, '30', 'EOM'); + + self::assertSame('45', (string)$resolved['two_day_on_invoice']); + self::assertSame('STANDARD', (string)$resolved['two_payment_term_type']); + } + + public function testSyncTwoAdminOrderPaymentDataFromProviderPullsLatestTermsFromTwo(): void + { + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-123') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'id' => 'two-123', + 'merchant_reference' => 'MR-123', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'invoice_url' => 'https://two.test/invoice/123', + 'invoice_details' => ['id' => 'inv-123'], + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => 'END_OF_MONTH', + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 55, + 'two_order_id' => 'two-123', + 'two_order_reference' => '', + 'two_order_state' => 'VERIFIED', + 'two_order_status' => 'APPROVED', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(55, $base); + + self::assertSame('60', (string)$synced['two_day_on_invoice']); + self::assertSame('EOM', (string)$synced['two_payment_term_type']); + self::assertSame('CONFIRMED', (string)$synced['two_order_state']); + self::assertSame('MR-123', (string)$synced['two_order_reference']); + self::assertSame(55, (int)$module->lastSavedOrderId); + self::assertSame('60', (string)$module->lastSavedPaymentData['two_day_on_invoice']); + } + + public function testSyncTwoAdminOrderPaymentDataFromProviderSupportsNestedDataEnvelope(): void + { + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-456') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'data' => [ + 'id' => 'two-456', + 'merchant_reference' => 'MR-456', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'invoice_url' => 'https://two.test/invoice/456', + 'invoice_details' => ['id' => 'inv-456'], + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => null, + ], + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 56, + 'two_order_id' => 'two-456', + 'two_order_reference' => '', + 'two_order_state' => '', + 'two_order_status' => '', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(56, $base); + + self::assertSame('60', (string)$synced['two_day_on_invoice']); + self::assertSame('STANDARD', (string)$synced['two_payment_term_type']); + self::assertSame('MR-456', (string)$synced['two_order_reference']); + self::assertSame(56, (int)$module->lastSavedOrderId); + } + + public function testSyncTwoAdminOrderPaymentDataFromProviderRecoversMissingTwoOrderIdFromAttempt(): void + { + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + protected function getLatestTwoCheckoutAttemptByOrder($id_order) + { + return array( + 'two_order_id' => 'two-789', + ); + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-789') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'id' => 'two-789', + 'merchant_reference' => 'MR-789', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => null, + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 57, + 'two_order_id' => '', + 'two_order_reference' => '', + 'two_order_state' => '', + 'two_order_status' => '', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(57, $base); + + self::assertSame('two-789', (string)$synced['two_order_id']); + self::assertSame('60', (string)$synced['two_day_on_invoice']); + self::assertSame('STANDARD', (string)$synced['two_payment_term_type']); + self::assertSame(57, (int)$module->lastSavedOrderId); + } + + public function testGetLatestTwoCheckoutAttemptByOrderSelectsTwoOrderIdForFallbackRecovery(): void + { + StubStore::reset(); + StubStore::$dbExecuteSResponses[] = array( + array( + 'two_order_id' => 'two-fallback-1', + 'status' => 'CANCELLED', + 'two_day_on_invoice' => '60', + 'two_payment_term_type' => 'STANDARD', + 'two_order_state' => 'CONFIRMED', + 'two_order_status' => 'PENDING', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ), + ); + + $module = new class extends TwopaymentTestHarness { + public function getLatestAttemptForTest($id_order) + { + return $this->getLatestTwoCheckoutAttemptByOrder($id_order); + } + }; + + $latest = $module->getLatestAttemptForTest(57); + + self::assertIsArray($latest); + self::assertSame('two-fallback-1', (string)$latest['two_order_id']); + self::assertNotEmpty(StubStore::$dbLastExecuteS); + self::assertStringContainsString('`two_order_id`', StubStore::$dbLastExecuteS[0]); + self::assertStringContainsString('`status`', StubStore::$dbLastExecuteS[0]); + self::assertStringContainsString('`id_order` = 57', StubStore::$dbLastExecuteS[0]); + } + + public function testGetTwoValidatedSessionCompanyDataRejectsCountryMismatch(): void + { + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme Ltd'; + $module->context->cookie->two_company_id = 'NO123'; + $module->context->cookie->two_company_country = 'NO'; + + $data = $module->getTwoValidatedSessionCompanyData('ES'); + + self::assertSame('', $data['company_name']); + self::assertSame('', $data['organization_number']); + self::assertFalse(isset($module->context->cookie->two_company_name)); + self::assertFalse(isset($module->context->cookie->two_company_id)); + self::assertFalse(isset($module->context->cookie->two_company_country)); + } + + public function testGetTwoValidatedSessionCompanyDataRejectsLegacySessionWithoutCountryMarker(): void + { + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme Ltd'; + $module->context->cookie->two_company_id = 'NO123'; + + $data = $module->getTwoValidatedSessionCompanyData('ES'); + + self::assertSame('', $data['company_name']); + self::assertSame('', $data['organization_number']); + self::assertFalse(isset($module->context->cookie->two_company_name)); + self::assertFalse(isset($module->context->cookie->two_company_id)); + } + + public function testBuildTwoApiResponseLogSummaryRedactsNestedProviderPayload(): void + { + $module = new TwopaymentTestHarness(); + + $summary = $module->buildTwoApiResponseLogSummary([ + 'http_status' => 400, + 'id' => 'two-order-1', + 'state' => 'CREATED', + 'status' => 'PENDING', + 'merchant_reference' => 'merchant-ref-1', + 'error' => 'validation_error', + 'data' => [ + 'invoice_url' => 'https://sensitive.example/invoice', + 'buyer' => ['email' => 'buyer@example.com'], + ], + ]); + + self::assertSame(400, $summary['http_status']); + self::assertSame('two-order-1', $summary['two_order_id']); + self::assertSame('CREATED', $summary['two_order_state']); + self::assertSame('PENDING', $summary['two_order_status']); + self::assertSame('merchant-ref-1', $summary['two_order_reference']); + self::assertSame('validation_error', $summary['error']); + self::assertFalse(isset($summary['data'])); + self::assertFalse(isset($summary['invoice_url'])); + } + + public function testGetTwoErrorMessageReturnsHttpFallbackForNonJsonProviderErrors(): void + { + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 502, + 'data' => null, + ]); + + self::assertSame('Two response code 502', $message); + } + + public function testGetTwoErrorMessageReadsNestedDataMessage(): void + { + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 400, + 'data' => [ + 'error_message' => 'Validation failed', + ], + ]); + + self::assertSame('Validation failed', $message); + } + + public function testGetTwoErrorMessageIgnoresSuccessMessagePayload(): void + { + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 200, + 'message' => 'Order confirmed', + ]); + + self::assertNull($message); + } + + public function testGetTwoProductItemsSkipsEmptyBarcodeEntries(): void + { + $module = new TwopaymentTestHarness(); + + $cart = new Cart(811); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[811] = [[ + 'id_product' => 701, + 'link_rewrite' => 'office-chair', + 'name' => 'Office Chair', + 'description_short' => 'Ergonomic chair', + 'manufacturer_name' => 'Acme', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + + StubStore::$productCategories[701] = [['name' => 'Furniture']]; + StubStore::$images[701] = ['id_image' => 9901]; + + $items = $module->getTwoProductItems($cart); + + self::assertCount(1, $items); + self::assertSame([], $items[0]['details']['barcodes']); + } + + public function testExtractOrgNumberFromAddressKeepsNonCountryPrefixVatNumber(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[812] = [ + 'id_country' => 826, + 'company' => 'Cheese Box Ltd', + 'vat_number' => 'SC806781', + 'loaded' => true, + ]; + + $address = new Address(812); + $orgNumber = $module->extractOrgNumberFromAddress($address, 'GB'); + + self::assertSame('SC806781', $orgNumber); + } + + public function testExtractOrgNumberFromAddressStripsMatchingCountryPrefixVatNumber(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[813] = [ + 'id_country' => 826, + 'company' => 'Cheese Box Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $address = new Address(813); + $orgNumber = $module->extractOrgNumberFromAddress($address, 'GB'); + + self::assertSame('123456789', $orgNumber); + } + + public function testGetTwoNewOrderDataKeepsDiscountTaxFormulaForLargeRoundedDiscounts(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[492] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-492', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[930] = [ + 'id_country' => 34, + 'company' => 'ORDER IN TECH', + 'companyid' => 'B01588177', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[931] = StubStore::$addresses[930]; + + StubStore::$carriers[32] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(492); + $cart->id_customer = 492; + $cart->id_currency = 978; + $cart->id_address_invoice = 930; + $cart->id_address_delivery = 931; + $cart->id_carrier = 32; + $cart->id_lang = 1; + + StubStore::$cartProducts[492] = [[ + 'id_product' => 778, + 'link_rewrite' => 'bulk-product', + 'name' => 'Bulk Product', + 'description_short' => 'Bulk', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 3000.00, + 'total_wt' => 3630.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 3000.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[778] = [['name' => 'Bulk']]; + StubStore::$images[778] = ['id_image' => 9902]; + StubStore::$cartShipping[492] = [ + true => 121.00, + false => 100.00, + ]; + StubStore::$cartTotals[492] = [ + true => [ + Cart::ONLY_DISCOUNTS => 709.25, + Cart::BOTH => 3041.75, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 586.25, + Cart::BOTH => 2513.75, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[492] = [ + ['name' => 'free shipping rule', 'code' => '', 'value' => -121.00, 'reduction_percent' => 0, 'reduction_amount' => 121.00, 'free_shipping' => 1], + ['name' => 'bulk promo', 'code' => '', 'value' => -588.25, 'reduction_percent' => 0, 'reduction_amount' => 588.25, 'free_shipping' => 0], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-492', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLine = null; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLine = $line; + break; + } + } + + self::assertNotNull($discountLine); + $lineTax = (float)$discountLine['tax_amount']; + $lineNet = (float)$discountLine['net_amount']; + $lineRate = (float)$discountLine['tax_rate']; + $diff = abs($lineTax - ($lineNet * $lineRate)); + self::assertLessThanOrEqual(0.02, $diff); + } + + public function testGetTwoNewOrderDataFallbackFreeShippingUsesShippingTaxContext(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[494] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Sara', + 'lastname' => 'Iglesias', + 'secure_key' => 'secure-key-494', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[942] = [ + 'id_country' => 34, + 'company' => 'Fallback Shop S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[943] = StubStore::$addresses[942]; + StubStore::$countries[34] = 'ES'; + + StubStore::$carriers[34] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(494); + $cart->id_customer = 494; + $cart->id_currency = 978; + $cart->id_address_invoice = 942; + $cart->id_address_delivery = 943; + $cart->id_carrier = 34; + $cart->id_lang = 1; + + StubStore::$cartProducts[494] = [ + [ + 'id_product' => 779, + 'link_rewrite' => 'zero-tax-item', + 'name' => 'Zero Tax Item', + 'description_short' => 'Zero', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 100.00, + 'cart_quantity' => 1, + 'rate' => 0.0, + 'price' => 100.00, + 'reduction' => 0, + ], + [ + 'id_product' => 780, + 'link_rewrite' => 'taxed-item', + 'name' => 'Taxed Item', + 'description_short' => 'Taxed', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 200.00, + 'total_wt' => 242.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 200.00, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[779] = [['name' => 'General']]; + StubStore::$productCategories[780] = [['name' => 'General']]; + StubStore::$images[779] = ['id_image' => 9903]; + StubStore::$images[780] = ['id_image' => 9904]; + StubStore::$cartShipping[494] = [ + true => 116.00, + false => 95.87, + ]; + StubStore::$cartTotals[494] = [ + true => [ + Cart::ONLY_DISCOUNTS => 116.00, + Cart::BOTH => 342.00, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 95.87, + Cart::BOTH => 300.00, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[494] = [ + ['name' => 'free shipping rule', 'code' => 'free-ship', 'value' => -116.00, 'reduction_amount' => 116.00, 'free_shipping' => 1], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-494', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLines = []; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLines[] = $line; + } + } + + self::assertCount(1, $discountLines); + self::assertSame('-116.00', (string)$discountLines[0]['gross_amount']); + self::assertSame('-95.87', (string)$discountLines[0]['net_amount']); + self::assertSame('-20.13', (string)$discountLines[0]['tax_amount']); + self::assertSame('0.21', (string)$discountLines[0]['tax_rate']); + } + + public function testGetTwoNewOrderDataSnapsSmallDiscountRateToCanonicalContext(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[496] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Eva', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-496', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[950] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[951] = StubStore::$addresses[950]; + + $cart = new Cart(496); + $cart->id_customer = 496; + $cart->id_currency = 978; + $cart->id_address_invoice = 950; + $cart->id_address_delivery = 951; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[496] = [ + [ + 'id_product' => 8301, + 'link_rewrite' => 'single-taxed-product', + 'name' => 'Single taxed product', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[8301] = [['name' => 'General']]; + StubStore::$images[8301] = ['id_image' => 8301]; + StubStore::$cartShipping[496] = [true => 0.00, false => 0.00]; + StubStore::$cartTotals[496] = [ + true => [ + Cart::ONLY_DISCOUNTS => 4.69, + Cart::BOTH => 116.31, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 3.87, + Cart::BOTH => 96.13, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[496] = [ + [ + 'name' => 'discount-rule-1', + 'code' => 'discount-rule-1', + 'value' => -4.69, + 'value_real' => 4.69, + 'value_tax_exc' => 3.87, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-496', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLine = null; + foreach ($payload['line_items'] as $line) { + if ((string)$line['name'] === 'discount-rule-1') { + $discountLine = $line; + break; + } + } + + self::assertNotNull($discountLine); + self::assertSame('0.21', (string)$discountLine['tax_rate']); + } + + public function testGetTwoNewOrderDataUsesCartRuleMonetaryValuesForDiscountLines(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[493] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'John', + 'lastname' => 'Jones', + 'secure_key' => 'secure-key-493', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[940] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'J13936695', + 'address1' => 'Billing here CALLE DALIA, 10 215', + 'city' => 'BENALMADENA', + 'postcode' => '29639', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[941] = [ + 'id_country' => 826, + 'company' => 'SPAIN LTD', + 'companyid' => '06922947', + 'address1' => 'Shipping here 20-22 Wenlock Road', + 'city' => 'London', + 'postcode' => 'N1 7GU', + 'phone' => '666666668', + 'loaded' => true, + ]; + + StubStore::$carriers[33] = [ + 'name' => 'My carrier', + 'delay' => 'Delivery next day!', + 'shipping_method' => Carrier::SHIPPING_METHOD_WEIGHT, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(493); + $cart->id_customer = 493; + $cart->id_currency = 978; + $cart->id_address_invoice = 940; + $cart->id_address_delivery = 941; + $cart->id_carrier = 33; + $cart->id_lang = 1; + + StubStore::$cartProducts[493] = [ + [ + 'id_product' => 8201, + 'link_rewrite' => 'hummingbird-printed-sweater', + 'name' => 'Hummingbird printed sweater', + 'description_short' => 'Sweater', + 'manufacturer_name' => 'Studio Design', + 'ean13' => '', + 'upc' => '', + 'total' => 430.80, + 'total_wt' => 430.80, + 'cart_quantity' => 12, + 'rate' => 0.0, + 'price' => 35.90, + 'reduction' => 0, + ], + [ + 'id_product' => 8202, + 'link_rewrite' => 'hummingbird-notebook', + 'name' => 'Hummingbird notebook', + 'description_short' => 'Notebook', + 'manufacturer_name' => 'Graphic Corner', + 'ean13' => '', + 'upc' => '', + 'total' => 10832.23, + 'total_wt' => 13107.00, + 'cart_quantity' => 17, + 'rate' => 21.0, + 'price' => 637.19, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[8201] = [['name' => 'Women']]; + StubStore::$productCategories[8202] = [['name' => 'Stationery']]; + StubStore::$images[8201] = ['id_image' => 8201]; + StubStore::$images[8202] = ['id_image' => 8202]; + StubStore::$cartShipping[493] = [ + true => 116.00, + false => 95.87, + ]; + StubStore::$cartTotals[493] = [ + true => [ + Cart::ONLY_DISCOUNTS => 731.99, + Cart::BOTH => 13138.40, + Cart::ONLY_SHIPPING => 0.00, + Cart::ONLY_WRAPPING => 216.59, + ], + false => [ + Cart::ONLY_DISCOUNTS => 608.99, + Cart::BOTH => 10928.91, + Cart::ONLY_SHIPPING => 0.00, + Cart::ONLY_WRAPPING => 179.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[493] = [ + [ + 'name' => 'free shipping rule', + 'code' => 'free-ship', + 'value' => -58.00, + 'value_real' => 58.00, + 'value_tax_exc' => 48.252911813643927, + ], + [ + 'name' => 'discount-rule', + 'code' => 'discount-rule', + 'value' => -673.99, + 'value_real' => 673.99, + 'value_tax_exc' => 560.73885440931781, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-493', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLines = []; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLines[] = $line; + } + } + + self::assertGreaterThanOrEqual(2, count($discountLines)); + + $aggregatedByRule = []; + foreach ($discountLines as $line) { + $lineName = (string)$line['name']; + $baseName = preg_replace('/\s+\(VAT\s+[^)]+\)$/', '', $lineName); + if (!isset($aggregatedByRule[$baseName])) { + $aggregatedByRule[$baseName] = ['net' => 0.0, 'gross' => 0.0]; + } + $aggregatedByRule[$baseName]['net'] += (float)$line['net_amount']; + $aggregatedByRule[$baseName]['gross'] += (float)$line['gross_amount']; + + $lineRate = (float)$line['tax_rate']; + $isCanonicalRate = abs($lineRate - 0.0) <= 0.000001 || abs($lineRate - 0.21) <= 0.000001; + self::assertTrue($isCanonicalRate, 'Expected discount tax_rate to stay on canonical contexts (0 or 0.21), got: ' . $line['tax_rate']); + } + + self::assertArrayHasKey('free shipping rule', $aggregatedByRule); + self::assertArrayHasKey('discount-rule', $aggregatedByRule); + self::assertSame('-48.25', number_format($aggregatedByRule['free shipping rule']['net'], 2, '.', '')); + self::assertSame('-560.74', number_format($aggregatedByRule['discount-rule']['net'], 2, '.', '')); + self::assertSame('-58.00', number_format($aggregatedByRule['free shipping rule']['gross'], 2, '.', '')); + self::assertSame('-673.99', number_format($aggregatedByRule['discount-rule']['gross'], 2, '.', '')); + } + + public function testMerchantCase1BuildsExpectedOrderPayload(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6101] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Uno', + 'secure_key' => 'secure-key-6101', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7101] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7102] = StubStore::$addresses[7101]; + StubStore::$carriers[710] = [ + 'name' => 'My carrier', + 'delay' => 'Delivery next day!', + 'shipping_method' => Carrier::SHIPPING_METHOD_WEIGHT, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6101); + $cart->id_customer = 6101; + $cart->id_currency = 978; + $cart->id_address_invoice = 7101; + $cart->id_address_delivery = 7102; + $cart->id_carrier = 710; + $cart->id_lang = 1; + + StubStore::$cartProducts[6101] = [[ + 'id_product' => 9101, + 'link_rewrite' => 'tv-lg-4k', + 'name' => 'TV LG 4K UHD, SmartTV con IA, 164 cm (65")', + 'description_short' => 'TV', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 1320.66, + 'total_wt' => 1598.00, + 'cart_quantity' => 2, + 'rate' => 21.0, + 'price' => 660.33, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9101] = [['name' => 'TV']]; + StubStore::$images[9101] = ['id_image' => 9101]; + StubStore::$cartShipping[6101] = [ + true => 58.00, + false => 47.93, + ]; + StubStore::$cartTotals[6101] = [ + true => [ + Cart::ONLY_DISCOUNTS => 137.90, + Cart::BOTH => 1518.10, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 113.96, + Cart::BOTH => 1254.63, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[6101] = [ + [ + 'name' => 'Envío gratis', + 'code' => 'free-shipping', + 'value' => -58.00, + 'value_real' => 58.00, + 'value_tax_exc' => 47.93, + 'free_shipping' => 1, + ], + [ + 'name' => 'Promo cruzada| 5%', + 'code' => 'cross-promo', + 'value' => -79.90, + 'value_real' => 79.90, + 'value_tax_exc' => 66.03, + 'free_shipping' => 0, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-case-1', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertSame('1518.10', (string)$payload['gross_amount']); + self::assertSame('1254.63', (string)$payload['net_amount']); + self::assertSame('263.47', (string)$payload['tax_amount']); + + $shippingSeen = false; + $freeShippingDiscountSeen = false; + $promoDiscountSeen = false; + foreach ($payload['line_items'] as $line) { + if ((string)$line['type'] === 'SHIPPING_FEE') { + $shippingSeen = true; + self::assertSame('58.00', (string)$line['gross_amount']); + } + if ((string)$line['name'] === 'Envío gratis') { + $freeShippingDiscountSeen = true; + self::assertSame('-58.00', (string)$line['gross_amount']); + } + if ((string)$line['name'] === 'Promo cruzada| 5%') { + $promoDiscountSeen = true; + self::assertSame('-79.90', (string)$line['gross_amount']); + } + } + self::assertTrue($shippingSeen); + self::assertTrue($freeShippingDiscountSeen); + self::assertTrue($promoDiscountSeen); + } + + public function testMerchantCase2BlocksOnInconsistentOrderTotals(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6102] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Dos', + 'secure_key' => 'secure-key-6102', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7201] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7202] = StubStore::$addresses[7201]; + StubStore::$carriers[720] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6102); + $cart->id_customer = 6102; + $cart->id_currency = 978; + $cart->id_address_invoice = 7201; + $cart->id_address_delivery = 7202; + $cart->id_carrier = 720; + $cart->id_lang = 1; + + StubStore::$cartProducts[6102] = [[ + 'id_product' => 9201, + 'link_rewrite' => 'lg-projector', + 'name' => 'LG CineBeam LED Projector with SmartTV WebOS', + 'description_short' => 'Projector', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 548.53, + 'total_wt' => 663.72, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 548.53, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9201] = [['name' => 'Projectors']]; + StubStore::$images[9201] = ['id_image' => 9201]; + StubStore::$cartShipping[6102] = [ + true => 2.99, + false => 2.47, + ]; + StubStore::$cartTotals[6102] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 25610.36, + Cart::ONLY_SHIPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 21165.59, + Cart::ONLY_SHIPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Order totals do not reconcile with cart totals'); + + $module->getTwoNewOrderData('merchant-case-2', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + } + + public function testMerchantCase3BuildsSimpleOrderPayload(): void + { + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6103] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Tres', + 'secure_key' => 'secure-key-6103', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7301] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7302] = StubStore::$addresses[7301]; + StubStore::$carriers[730] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6103); + $cart->id_customer = 6103; + $cart->id_currency = 978; + $cart->id_address_invoice = 7301; + $cart->id_address_delivery = 7302; + $cart->id_carrier = 730; + $cart->id_lang = 1; + + StubStore::$cartProducts[6103] = [[ + 'id_product' => 9301, + 'link_rewrite' => 'lg-xboom', + 'name' => 'LG XBOOM High Voltage Speaker, 1000W', + 'description_short' => 'Speaker', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 409.24, + 'total_wt' => 495.18, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 409.24, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9301] = [['name' => 'Audio']]; + StubStore::$images[9301] = ['id_image' => 9301]; + StubStore::$cartShipping[6103] = [ + true => 2.99, + false => 2.47, + ]; + StubStore::$cartTotals[6103] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 498.17, + Cart::ONLY_SHIPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 411.71, + Cart::ONLY_SHIPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-case-3', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + self::assertSame('498.17', (string)$payload['gross_amount']); + self::assertSame('411.71', (string)$payload['net_amount']); + self::assertSame('86.46', (string)$payload['tax_amount']); + + $hasProduct = false; + $hasShipping = false; + foreach ($payload['line_items'] as $line) { + if ((string)$line['type'] === 'PHYSICAL') { + $hasProduct = true; + } + if ((string)$line['type'] === 'SHIPPING_FEE') { + $hasShipping = true; + self::assertSame('2.99', (string)$line['gross_amount']); + self::assertSame('0.21', (string)$line['tax_rate']); + } + } + self::assertTrue($hasProduct); + self::assertTrue($hasShipping); + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d7fa05e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,73 @@ +# twopayment test suite + +This folder contains deterministic tests for order-building and payload safety logic. + +## What is covered + +- Line item formula validation (`tax_amount = net_amount * tax_rate` and net formula) +- Tax subtotal grouping and decimal tax-rate precision retention +- Product tax-rate derivation when configured and applied rates differ +- Non-integer VAT handling for line-item formula safety (e.g. 5.5%) +- Order-level tax-rate derivation from final net/tax totals +- Guardrails that reject invalid line items before building order payloads +- Snapshot hash sensitivity to tax-rate precision changes beyond two decimals +- Gift wrapping payload line composition and reconciliation safety +- Currency compatibility gating for payment option visibility +- Large rounded discount split handling keeps tax-formula validation stable +- Cart-rule monetary (`value_real`/`value_tax_exc`) discount line attribution + +## Why this matters + +These tests protect payment-critical invariants: +- Two payloads must reflect PrestaShop totals exactly +- Tax math must remain internally consistent +- Small precision regressions must not silently alter snapshot/idempotency behavior + +## Run tests (offline) + +```bash +php tests/run.php +``` + +## Recommended pre-commit checks + +From module root: + +```bash +php -l twopayment.php +php tests/run.php +``` + +If you touched additional PHP files, lint them too: + +```bash +php -l path/to/file.php +``` + +## Optional PHPUnit setup (if network access is available) + +```bash +composer install +composer test +``` + +PHPUnit config and equivalent test file are included: + +- `phpunit.xml.dist` +- `tests/OrderBuilderTest.php` + +## Real-engine integration matrix + +Integration coverage requirements for PrestaShop `1.7.8`, `8.x`, and `9.x` live in: + +- `tests/integration/README.md` + +This matrix validates cart-rule/tax/discount parity against real PrestaShop checkout/cart behavior, beyond the offline deterministic harness. + +## When to add tests + +Add or update tests when you change: +- Tax derivation logic +- Order line item/net/gross/tax formulas +- Shipping/discount payload composition +- Snapshot or idempotency-sensitive hash inputs diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e917fe6 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,811 @@ + false, + 'PS_TWO_PAYMENT_TERM_TYPE' => 'STANDARD', + 'PS_TWO_ENVIRONMENT' => 'development', + 'PS_OS_SHIPPING' => 4, + 'PS_OS_CANCELED' => 6, + ]; + self::$countries = [34 => 'ES', 47 => 'NO', 56 => 'BE']; + self::$states = [ + 1 => 'Madrid', + 2 => 'Oslo', + ]; + self::$customers = []; + self::$currencies = []; + self::$addresses = []; + self::$carriers = []; + self::$cartProducts = []; + self::$cartTotals = []; + self::$cartShipping = []; + self::$cartRules = []; + self::$moduleCurrencies = [ + 'twopayment' => [ + ['id_currency' => 578], // NOK + ['id_currency' => 826], // GBP + ['id_currency' => 752], // SEK + ['id_currency' => 840], // USD + ['id_currency' => 208], // DKK + ['id_currency' => 978], // EUR + ], + ]; + self::$productCategories = []; + self::$images = []; + self::$taxRuleRates = []; + self::$dbExecuteSResponses = []; + self::$dbLastExecuteS = []; + + $context = Context::getContext(); + $context->cookie = new Cookie(); + $context->link = new Link(); + $context->controller = new \stdClass(); + $context->language = (object) ['id' => 1]; + $context->smarty = new class { + public function assign($vars): void + { + } + + public function fetch($template): string + { + return ''; + } + }; + + if (class_exists('Tools') && method_exists('Tools', 'resetTestValues')) { + Tools::resetTestValues(); + } + } + } + + class Module + { + public int $id = 1; + + public static function isInstalled($name): bool + { + return (string) $name === 'twopayment'; + } + + public static function isEnabled($name): bool + { + return (string) $name === 'twopayment'; + } + } + + class PaymentModule extends Module + { + public string $name = 'twopayment'; + public string $version = '2.4.0'; + public string $displayName = 'Two'; + public string $merchant_short_name = 'merchant'; + public string $api_key = 'test-api-key'; + public bool $active = true; + public array $languages = []; + public $context; + public int $currentOrder = 0; + + public function __construct() + { + $this->context = Context::getContext(); + } + + public function l($string) + { + return $string; + } + + public function displayConfirmation($message): string + { + return (string) $message; + } + + public function displayError($message): string + { + return (string) $message; + } + + public function display($file, $template): string + { + return ''; + } + + public function getCurrency($idCurrency = null): array + { + $moduleName = property_exists($this, 'name') ? (string) $this->name : ''; + $currencies = StubStore::$moduleCurrencies[$moduleName] ?? []; + if ($idCurrency === null) { + return $currencies; + } + + $idCurrency = (int) $idCurrency; + $filtered = []; + foreach ($currencies as $currency) { + if ((int) ($currency['id_currency'] ?? 0) === $idCurrency) { + $filtered[] = $currency; + } + } + + return $filtered; + } + } + + class Context + { + public $cookie; + public $link; + public $controller; + public $cart; + public $language; + public $smarty; + + private static ?self $instance = null; + + public static function getContext(): self + { + if (self::$instance === null) { + self::$instance = new self(); + self::$instance->cookie = new Cookie(); + self::$instance->link = new Link(); + self::$instance->controller = new \stdClass(); + self::$instance->language = (object) ['id' => 1]; + self::$instance->smarty = new class { + public function assign($vars): void + { + } + + public function fetch($template): string + { + return ''; + } + }; + } + + return self::$instance; + } + } + + #[\AllowDynamicProperties] + class Cookie + { + public function setExpire(int $timestamp): void + { + } + + public function write(): void + { + } + } + + class Link + { + public function getImageLink($rewrite, $idImage, $type): string + { + return 'https://img.local/' . $idImage; + } + + public function getProductLink($idProduct): string + { + return 'https://shop.local/product/' . $idProduct; + } + + public function getModuleLink($module, $controller, $params = [], $ssl = true): string + { + $query = http_build_query((array) $params); + return 'https://shop.local/module/' . $module . '/' . $controller . ($query !== '' ? '?' . $query : ''); + } + } + + class Validate + { + public static function isLoadedObject($object): bool + { + if (!is_object($object)) { + return false; + } + + if (property_exists($object, 'loaded')) { + return (bool) $object->loaded; + } + + return true; + } + } + + class Configuration + { + public static function get($key, $default = null) + { + return array_key_exists($key, StubStore::$configuration) ? StubStore::$configuration[$key] : $default; + } + + public static function updateValue($key, $value): bool + { + StubStore::$configuration[$key] = $value; + return true; + } + } + + class PrestaShopLogger + { + public static function addLog($message, $severity = 1, $errorCode = null, $objectType = null, $objectId = null, $allowDuplicate = false): bool + { + return true; + } + } + + class Tools + { + private static array $testValues = []; + + public static function substr($string, $start, $length = null) + { + return $length === null ? substr((string) $string, (int) $start) : substr((string) $string, (int) $start, (int) $length); + } + + public static function strlen($string): int + { + return strlen((string) $string); + } + + public static function isEmpty($value): bool + { + if ($value === null) { + return true; + } + + if (is_string($value)) { + return trim($value) === ''; + } + + return $value === ''; + } + + public static function displayPrice($amount): string + { + return number_format((float) $amount, 2, '.', ''); + } + + public static function ps_round($value, $precision = 2): float + { + return round((float) $value, (int) $precision); + } + + public static function getValue($key, $default = null) + { + return array_key_exists((string) $key, self::$testValues) ? self::$testValues[(string) $key] : $default; + } + + public static function setTestValue($key, $value): void + { + self::$testValues[(string) $key] = $value; + } + + public static function resetTestValues(): void + { + self::$testValues = []; + } + + public static function getToken($page = false): string + { + return 'token'; + } + + public static function strtolower($value): string + { + return strtolower((string) $value); + } + } + + class Country + { + public static function getIsoById($id) + { + return StubStore::$countries[(int) $id] ?? false; + } + } + + class State + { + public static function getNameById($id) + { + return StubStore::$states[(int) $id] ?? ''; + } + } + + class Product + { + public static function getProductCategoriesFull($idProduct, $idLang) + { + return StubStore::$productCategories[(int) $idProduct] ?? []; + } + } + + class Image + { + public static function getCover($idProduct) + { + return StubStore::$images[(int) $idProduct] ?? ['id_image' => 1]; + } + } + + class ImageType + { + public static function getFormattedName($name) + { + return $name; + } + } + + class TaxCalculator + { + private float $rate; + + public function __construct(float $rate) + { + $this->rate = $rate; + } + + public function getTotalRate(): float + { + return $this->rate; + } + } + + class TaxManager + { + private int $groupId; + + public function __construct(int $groupId) + { + $this->groupId = $groupId; + } + + public function getTaxCalculator(): TaxCalculator + { + return new TaxCalculator((float) (StubStore::$taxRuleRates[$this->groupId] ?? 0.0)); + } + } + + class TaxManagerFactory + { + public static function getManager($address, $taxRulesGroupId): TaxManager + { + return new TaxManager((int) $taxRulesGroupId); + } + } + + class Carrier + { + public const SHIPPING_METHOD_WEIGHT = 1; + public const SHIPPING_METHOD_PRICE = 2; + + public bool $loaded = false; + public string $name = ''; + public $delay = ''; + public int $shipping_method = 0; + public int $max_delivery_days = 0; + public int $min_delivery_days = 0; + private int $taxRulesGroupId = 0; + + public function __construct($idCarrier = null, $idLang = null) + { + $id = (int) $idCarrier; + if ($id > 0 && isset(StubStore::$carriers[$id])) { + $data = StubStore::$carriers[$id]; + $this->loaded = true; + $this->name = (string) ($data['name'] ?? 'Carrier'); + $this->delay = $data['delay'] ?? ''; + $this->shipping_method = (int) ($data['shipping_method'] ?? 0); + $this->max_delivery_days = (int) ($data['max_delivery_days'] ?? 0); + $this->min_delivery_days = (int) ($data['min_delivery_days'] ?? 0); + $this->taxRulesGroupId = (int) ($data['tax_rules_group_id'] ?? 0); + } + } + + public function getIdTaxRulesGroup(): int + { + return $this->taxRulesGroupId; + } + } + + class Address + { + public static array $definition = [ + 'fields' => [ + 'account_type' => ['validate' => 'isGenericName', 'size' => 32], + 'department' => ['validate' => 'isGenericName', 'size' => 255], + 'project' => ['validate' => 'isGenericName', 'size' => 255], + ], + ]; + + public bool $loaded = false; + public int $id = 0; + public int $id_country = 0; + public int $id_state = 0; + public string $company = ''; + public string $companyid = ''; + public string $vat_number = ''; + public string $dni = ''; + public string $address1 = ''; + public string $address2 = ''; + public string $city = ''; + public string $postcode = ''; + public string $phone = ''; + public string $phone_mobile = ''; + public string $department = ''; + public string $project = ''; + public string $account_type = ''; + + public function __construct($id = null) + { + $id = (int) $id; + if ($id > 0 && isset(StubStore::$addresses[$id])) { + foreach (StubStore::$addresses[$id] as $key => $value) { + $this->{$key} = $value; + } + $this->id = $id; + $this->loaded = true; + } + } + } + + class FormField + { + private string $name = ''; + private string $type = 'text'; + private string $label = ''; + private bool $required = false; + private array $availableValues = []; + private array $constraints = []; + private ?int $maxLength = null; + + public function setName($name): self + { + $this->name = (string) $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setType($type): self + { + $this->type = (string) $type; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setLabel($label): self + { + $this->label = (string) $label; + return $this; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setRequired($required): self + { + $this->required = (bool) $required; + return $this; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function addAvailableValue($key, $value): self + { + $this->availableValues[(string) $key] = $value; + return $this; + } + + public function getAvailableValue($key) + { + return $this->availableValues[(string) $key] ?? null; + } + + public function addConstraint($constraint): self + { + $this->constraints[] = $constraint; + return $this; + } + + public function setMaxLength($maxLength): self + { + $this->maxLength = (int) $maxLength; + return $this; + } + } + + class Customer + { + public bool $loaded = false; + public int $id = 0; + public string $email = ''; + public string $firstname = ''; + public string $lastname = ''; + public string $secure_key = 'secure'; + + public function __construct($id = null) + { + $id = (int) $id; + if ($id > 0 && isset(StubStore::$customers[$id])) { + foreach (StubStore::$customers[$id] as $key => $value) { + $this->{$key} = $value; + } + $this->id = $id; + $this->loaded = true; + } + } + } + + class Currency + { + public bool $loaded = false; + public int $id = 0; + public string $iso_code = 'EUR'; + + public function __construct($id = null) + { + $id = (int) $id; + if ($id > 0 && isset(StubStore::$currencies[$id])) { + foreach (StubStore::$currencies[$id] as $key => $value) { + $this->{$key} = $value; + } + $this->id = $id; + $this->loaded = true; + } + } + } + + class Cart + { + public const ONLY_DISCOUNTS = 1; + public const ONLY_SHIPPING = 2; + public const BOTH = 3; + public const ONLY_WRAPPING = 4; + + public bool $loaded = true; + public int $id = 0; + public int $id_customer = 0; + public int $id_currency = 0; + public int $id_address_invoice = 0; + public int $id_address_delivery = 0; + public int $id_carrier = 0; + public int $id_lang = 1; + + public function __construct($id = null) + { + $id = (int) $id; + if ($id > 0) { + $this->id = $id; + } + } + + public function nbProducts(): int + { + return count(StubStore::$cartProducts[$this->id] ?? []); + } + + public function getProducts($refresh = false): array + { + return StubStore::$cartProducts[$this->id] ?? []; + } + + public function getOrderTotal($withTaxes, $type) + { + $withTaxes = (bool) $withTaxes; + if (isset(StubStore::$cartTotals[$this->id][$withTaxes][$type])) { + return StubStore::$cartTotals[$this->id][$withTaxes][$type]; + } + return 0.0; + } + + public function getPackageShippingCost($idCarrier, $useTax, $defaultCountry = null, $productList = null, $idZone = null) + { + $useTax = (bool) $useTax; + if (isset(StubStore::$cartShipping[$this->id][$useTax])) { + return StubStore::$cartShipping[$this->id][$useTax]; + } + return 0.0; + } + + public function getCartRules(): array + { + return StubStore::$cartRules[$this->id] ?? []; + } + + public function getAverageProductsTaxRate(): float + { + return (float) (StubStore::$cartTotals[$this->id]['average_products_tax_rate'] ?? 0.0); + } + } + + class Language + { + public static function getLanguages($active = false): array + { + return []; + } + } + + class Shop + { + public const CONTEXT_ALL = 1; + + public static function isFeatureActive(): bool + { + return false; + } + + public static function setContext($context): void + { + } + } + + class Db + { + public static function getInstance($useMaster = true): self + { + return new self(); + } + + public function execute($sql): bool + { + return true; + } + + public function executeS($sql): array + { + StubStore::$dbLastExecuteS[] = (string) $sql; + if (!empty(StubStore::$dbExecuteSResponses)) { + $next = array_shift(StubStore::$dbExecuteSResponses); + return is_array($next) ? $next : []; + } + return []; + } + + public function insert($table, $data): bool + { + return true; + } + + public function update($table, $data, $where): bool + { + return true; + } + } + + class OrderState + { + public bool $loaded = true; + public int $id = 1; + public $name = ''; + public int $invoice = 0; + public int $delivery = 0; + public int $shipped = 0; + public int $paid = 0; + public int $logable = 0; + public int $send_email = 0; + public string $template = ''; + public string $color = ''; + public int $hidden = 0; + + public function __construct($id = 1, $idLang = null) + { + $id = (int)$id; + if ($id > 0) { + $this->id = $id; + } + + $shipping_status = (int)(StubStore::$configuration['PS_OS_SHIPPING'] ?? 4); + $cancelled_status = (int)(StubStore::$configuration['PS_OS_CANCELED'] ?? 6); + $two_cancelled_status = (int)(StubStore::$configuration['PS_TWO_OS_CANCELLED'] ?? 0); + $two_cancelled_map = (int)(StubStore::$configuration['PS_TWO_OS_CANCELLED_MAP'] ?? 0); + + if ($this->id === $shipping_status) { + $this->name = 'Shipped'; + $this->shipped = 1; + $this->logable = 1; + return; + } + + if ($this->id === $cancelled_status || ($two_cancelled_status > 0 && $this->id === $two_cancelled_status) || ($two_cancelled_map > 0 && $this->id === $two_cancelled_map)) { + $this->name = 'Cancelled'; + $this->shipped = 0; + $this->logable = 0; + return; + } + } + + public function add(): bool + { + return true; + } + } + + class Tab + { + public static function getIdFromClassName($className): int + { + return 1; + } + } + + require_once dirname(__DIR__) . '/twopayment.php'; + + class TwopaymentTestHarness extends Twopayment + { + public function __construct() + { + $this->context = Context::getContext(); + $this->name = 'twopayment'; + $this->version = '2.4.0'; + $this->merchant_short_name = 'merchant'; + $this->api_key = 'test-api-key'; + $this->languages = [['id_lang' => 1]]; + $this->active = true; + } + + public function l($string) + { + return $string; + } + } + + StubStore::reset(); +} diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..da05319 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,44 @@ +# Two Payment Integration Matrix (PrestaShop Real Engine) + +This folder documents the real-engine integration matrix required for order build parity validation across PrestaShop versions. + +## Target versions + +- PrestaShop `1.7.8` +- PrestaShop `8.x` +- PrestaShop `9.x` + +## Mandatory scenarios + +- Mixed VAT rates + fixed voucher +- Mixed VAT rates + percentage voucher +- Free shipping cart rule only +- Free shipping + additional cart discount +- Gift wrapping (taxed and zero-tax variants) +- Ecotax product in cart (when store config enables ecotax) +- Zero-tax + taxed product mix +- Specific-price reduction + cart-rule discount combination +- Rounding mode variants: +- `ROUND_ITEM` +- `ROUND_LINE` +- `ROUND_TOTAL` + +## Assertions per scenario + +- Two payload line items reconcile to cart totals: +- `sum(net_amount) == cart net` (tolerance 0.02 unless scenario expects hard-block) +- `sum(tax_amount) == cart tax` +- `sum(gross_amount) == cart gross` +- `gross == net + tax` for each line item +- `tax_amount == net_amount * tax_rate` within formula tolerance +- `tax_subtotals` match line-item tax aggregation +- Payment-submit authoritative order intent uses strict reconciliation gate +- Callback local order creation is race-safe (no duplicate local orders) + +## Execution notes + +- These are **integration tests** against a running PrestaShop instance, not the offline unit harness in `tests/run.php`. +- Keep provider-first flow intact: +- No local order creation before successful provider verification callback. +- Build payload from actual cart state after cart rules are applied. +- Record scenario evidence (request payload, cart totals, and outcome) for each PrestaShop version. diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..ce10b64 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,4548 @@ +getMessage(), $expectedMessage) === false) { + throw new RuntimeException('Expected exception message containing "' . $expectedMessage . '", got "' . $e->getMessage() . '"'); + } + return; + } + + throw new RuntimeException('Expected exception was not thrown'); + } +} + +final class OrderBuilderSpec +{ + public static function runAll(): void + { + self::testValidateTwoLineItemsRejectsBrokenTaxFormula(); + self::testGetTwoTaxSubtotalsNormalizesTaxRateToTwoDecimals(); + self::testGetTwoProductItemsUsesAppliedTaxRateWhenConfiguredRateDiffers(); + self::testGetTwoProductItemsSplitsEcotaxIntoServiceLine(); + self::testGetTwoNewOrderDataSupportsFivePointFivePercentVat(); + self::testGetTwoNewOrderDataIncludesGiftWrappingLine(); + self::testGetTwoNewOrderDataSnapsGiftWrappingRateToCanonicalContext(); + self::testGetTwoProductItemsSnapsMinorRateDriftToKnownProductContexts(); + self::testGetTwoProductItemsSpanishFallbackDefaultsToTwentyOnePercentWithoutKnownContext(); + self::testGetTwoNewOrderDataOmitsTopLevelTaxRate(); + self::testGetTwoNewOrderDataOmitsTaxSubtotalsWhenDisabled(); + self::testGetTwoIntentOrderDataOmitsTopLevelTaxRateAndOmitsTaxSubtotalsWhenDisabled(); + self::testGetTwoNewOrderDataThrowsWhenLineItemsFailFormulaValidation(); + self::testGetTwoNewOrderDataThrowsWhenCartTotalsMismatchIsMaterial(); + self::testGetTwoIntentOrderDataContinuesWhenCartTotalsDoNotReconcile(); + self::testGetTwoNewOrderDataAllowsTwoCentReconciliationDrift(); + self::testGetTwoNewOrderDataAllowsTwoCentBoundaryForLargeTotals(); + self::testGetTwoNewOrderDataBlocksThreeCentReconciliationDrift(); + self::testGetTwoNewOrderDataIncludesShippingAndDiscountLineItemsWhenReconciled(); + self::testGetTwoNewOrderDataFallbackFreeShippingUsesShippingTaxContext(); + self::testGetTwoNewOrderDataUsesCartRuleMonetaryValuesForDiscountLines(); + self::testGetTwoNewOrderDataHandlesMixedCartRuleMetadataWithPartialFallback(); + self::testGetTwoNewOrderDataMixedMetadataKeepsCompleteRuleValuesAndFreeShippingContext(); + self::testGetTwoNewOrderDataSpanishOddDecimalsKeepCanonicalTwentyOneDiscountRates(); + self::testGetTwoNewOrderDataSpanishTinyPartialFallbackKeepsCanonicalRates(); + self::testGetTwoNewOrderDataSnapsSmallDiscountRateToCanonicalContext(); + self::testGetTwoNewOrderDataKeepsDiscountTaxFormulaForLargeRoundedDiscounts(); + self::testMerchantCase1BuildsExpectedOrderPayload(); + self::testMerchantCase2BlocksOnInconsistentOrderTotals(); + self::testMerchantCase3BuildsSimpleOrderPayload(); + self::testGetTwoRequestHeadersSkipApiKeyForOrderIntent(); + self::testCheckTwoOrderIntentApprovalAtPaymentDeclinesEvenWhenFrontendCookieSaysApproved(); + self::testCheckTwoOrderIntentApprovalAtPaymentAllowsApprovedResponse(); + self::testCheckTwoOrderIntentApprovalAtPaymentHandlesProviderNetworkFailure(); + self::testCheckTwoOrderIntentApprovalAtPaymentBlocksOnStrictReconciliationDrift(); + self::testExtractTwoProviderGrossAmountForValidationSupportsRootAndNestedPayloads(); + self::testCreateTwoLocalOrderAfterProviderVerificationRecoversExistingOrderOnRace(); + self::testCreateTwoLocalOrderAfterProviderVerificationFailsWhenNoRecoverableOrderExists(); + self::testCancelTwoOrderBestEffortReturnsTrueOnSuccessAndFalseOnFailure(); + self::testSnapshotHashIgnoresTaxRateChangesBeyondTwoDecimals(); + self::testBuildTwoOrderCreateIdempotencyKeyIsDeterministicForSameSnapshot(); + self::testHasTwoOrderRebindingConflictDetectsMismatchedTwoOrderIds(); + self::testIsTwoAttemptCallbackAuthorizedWithMatchingKey(); + self::testIsTwoAttemptCallbackAuthorizedFallsBackToContextCustomerKeyWhenRequestKeyMissing(); + self::testIsTwoAttemptCallbackAuthorizedRejectsMismatchedKeys(); + self::testGetTwoBuyerPortalUrlUsesEnvironmentSpecificBuyerDomains(); + self::testResolveTwoAttemptOrderIdForCancellationPrefersAttemptOrderId(); + self::testResolveTwoAttemptOrderIdForCancellationFallsBackToCartOrderId(); + self::testShouldBlockTwoAttemptConfirmationByStatusOnlyForCancelled(); + self::testIsTwoAttemptStatusTerminalMatchesCancelledGuard(); + self::testGetTwoCancelledOrderStatusIdUsesConfiguredFallbackChain(); + self::testSyncLocalOrderStatusFromTwoStateCancelsOnlyWhenProviderCancelled(); + self::testIsTwoOrderCancelledResponseRequires2xxAndCancelledState(); + self::testShouldBlockTwoFulfillmentByTwoStateOnlyForCancelled(); + self::testShouldBlockTwoStatusTransitionByCancelledStateCoversVerifiedAndFulfillment(); + self::testIsTwoOrderFulfillableStateRequiresConfirmed(); + self::testAddTwoBackOfficeWarningAppendsUniqueWarning(); + self::testAddTwoBackOfficeWarningReturnsFalseWhenNoController(); + self::testApplyTwoCancelledOrderStateProfileToStatusObjectUsesConfiguredCancelledState(); + self::testForceTwoCancelledOrderHistoryStateBeforeInsertRewritesPendingStatus(); + self::testGetTwoCheckoutCompanyDataUsesAddressVatNumberForAnyCountry(); + self::testGetTwoCheckoutCompanyDataPrefersCurrentAddressOrgNumberOverSessionCompany(); + self::testGetTwoCheckoutCompanyDataUsesValidatedCookieFallback(); + self::testGetTwoCheckoutCompanyDataClearsStaleCookieOnCountryMismatch(); + self::testGetTwoCheckoutCompanyDataIgnoresStaleCookieWhenAddressCompanyChangesSameCountry(); + self::testSaveGeneralFormDoesNotChangeSslVerificationFlag(); + self::testSaveOtherFormUpdatesSslVerificationFlag(); + self::testOtherSettingsFormDoesNotExposeOrderIntentToggle(); + self::testHookActionAdminControllerSetMediaRegistersCssOnModuleConfigPage(); + self::testHookActionAdminControllerSetMediaSkipsUnrelatedAdminPage(); + self::testHookPaymentOptionsBlocksWhenAccountTypeMissingInStrictMode(); + self::testHookPaymentOptionsBlocksNonBusinessWhenAccountTypePresent(); + self::testHookPaymentOptionsAllowsTwoCoveredCurrencies(); + self::testHookPaymentOptionsBlocksUnsupportedCurrency(); + self::testMergeTwoPaymentTermFallbackUsesFallbackWhenMissing(); + self::testMergeTwoPaymentTermFallbackKeepsExistingValues(); + self::testShouldExposeTwoInvoiceActionsRequiresFulfilledState(); + self::testResolveTwoPaymentTermsFromOrderResponseUsesEndOfMonthAsEom(); + self::testResolveTwoPaymentTermsFromOrderResponseFallsBackToStandardForUnsupportedScheme(); + self::testSyncTwoAdminOrderPaymentDataFromProviderPullsLatestTermsFromTwo(); + self::testSyncTwoAdminOrderPaymentDataFromProviderSupportsNestedDataEnvelope(); + self::testSyncTwoAdminOrderPaymentDataFromProviderRecoversMissingTwoOrderIdFromAttempt(); + self::testGetLatestTwoCheckoutAttemptByOrderSelectsTwoOrderIdForFallbackRecovery(); + self::testGetTwoValidatedSessionCompanyDataRejectsCountryMismatch(); + self::testGetTwoValidatedSessionCompanyDataRejectsLegacySessionWithoutCountryMarker(); + self::testBuildTwoApiResponseLogSummaryRedactsNestedProviderPayload(); + self::testGetTwoErrorMessageReturnsHttpFallbackForNonJsonProviderErrors(); + self::testGetTwoErrorMessageReadsNestedDataMessage(); + self::testGetTwoErrorMessageIgnoresSuccessMessagePayload(); + self::testGetTwoProductItemsSkipsEmptyBarcodeEntries(); + self::testExtractOrgNumberFromAddressKeepsNonCountryPrefixVatNumber(); + self::testExtractOrgNumberFromAddressStripsMatchingCountryPrefixVatNumber(); + } + + private static function reset(): void + { + StubStore::reset(); + } + + private static function testValidateTwoLineItemsRejectsBrokenTaxFormula(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $lineItems = [[ + 'name' => 'TV', + 'net_amount' => '100.00', + 'tax_amount' => '15.00', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'discount_amount' => '0.00', + ]]; + + TinyAssert::false($module->validateTwoLineItems($lineItems)); + } + + private static function testGetTwoTaxSubtotalsNormalizesTaxRateToTwoDecimals(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $lineItems = [ + ['tax_rate' => '0.205', 'net_amount' => '100.00', 'tax_amount' => '20.50'], + ['tax_rate' => '0.205', 'net_amount' => '50.00', 'tax_amount' => '10.25'], + ['tax_rate' => '0.21', 'net_amount' => '200.00', 'tax_amount' => '42.00'], + ]; + + $subtotals = $module->getTwoTaxSubtotals($lineItems); + + TinyAssert::count(1, $subtotals); + TinyAssert::same('0.21', $subtotals[0]['tax_rate']); + TinyAssert::same('350.00', $subtotals[0]['taxable_amount']); + TinyAssert::same('72.75', $subtotals[0]['tax_amount']); + } + + private static function testGetTwoProductItemsUsesAppliedTaxRateWhenConfiguredRateDiffers(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new Cart(10); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[10] = [[ + 'id_product' => 501, + 'link_rewrite' => 'smart-tv', + 'name' => 'Smart TV', + 'description_short' => 'Test description', + 'manufacturer_name' => 'LG', + 'ean13' => '1234567890123', + 'upc' => '012345678905', + 'total' => 100.00, + 'total_wt' => 120.50, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + + StubStore::$productCategories[501] = [['name' => 'Electronics']]; + StubStore::$images[501] = ['id_image' => 9001]; + + $items = $module->getTwoProductItems($cart); + + TinyAssert::count(1, $items); + TinyAssert::same('0.205', $items[0]['tax_rate']); + TinyAssert::same('20.50', $items[0]['tax_amount']); + TinyAssert::same('120.50', $items[0]['gross_amount']); + } + + private static function testGetTwoProductItemsSplitsEcotaxIntoServiceLine(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new Cart(11); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[11] = [[ + 'id_product' => 777, + 'link_rewrite' => 'eco-product', + 'name' => 'Eco Product', + 'description_short' => 'Eco friendly', + 'manufacturer_name' => 'Green Co', + 'ean13' => '', + 'upc' => '', + 'total' => 110.00, + 'total_wt' => 131.55, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 110.00, + 'reduction' => 0, + 'ecotax' => 10.00, + 'ecotax_tax_rate' => 5.5, + ]]; + + StubStore::$productCategories[777] = [['name' => 'Accessories']]; + StubStore::$images[777] = ['id_image' => 9011]; + + $items = $module->getTwoProductItems($cart); + + TinyAssert::count(2, $items); + TinyAssert::same('PHYSICAL', $items[0]['type']); + TinyAssert::same('100.00', (string)$items[0]['net_amount']); + TinyAssert::same('121.00', (string)$items[0]['gross_amount']); + TinyAssert::same('21.00', (string)$items[0]['tax_amount']); + TinyAssert::same('0.21', (string)$items[0]['tax_rate']); + + TinyAssert::same('SERVICE', $items[1]['type']); + TinyAssert::same('10.00', (string)$items[1]['net_amount']); + TinyAssert::same('10.55', (string)$items[1]['gross_amount']); + TinyAssert::same('0.55', (string)$items[1]['tax_amount']); + TinyAssert::same('0.055', (string)$items[1]['tax_rate']); + } + + private static function testGetTwoNewOrderDataSupportsFivePointFivePercentVat(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7001] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Eva', + 'lastname' => 'Martin', + 'secure_key' => 'secure-key-7001', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[7101] = [ + 'id_country' => 33, + 'company' => 'Acme FR SAS', + 'companyid' => 'FR123456789', + 'address1' => '10 Rue de Paris', + 'city' => 'Paris', + 'postcode' => '75001', + 'phone' => '+33100000000', + 'loaded' => true, + ]; + StubStore::$addresses[7102] = StubStore::$addresses[7101]; + StubStore::$countries[33] = 'FR'; + + $cart = new Cart(7001); + $cart->id_customer = 7001; + $cart->id_currency = 978; + $cart->id_address_invoice = 7101; + $cart->id_address_delivery = 7102; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7001] = [[ + 'id_product' => 9301, + 'link_rewrite' => 'reduced-vat-item', + 'name' => 'Reduced VAT item', + 'description_short' => 'Reduced VAT test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 105.50, + 'cart_quantity' => 1, + 'rate' => 5.5, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9301] = [['name' => 'Books']]; + StubStore::$images[9301] = ['id_image' => 9301]; + StubStore::$cartTotals[7001] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 105.50, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 5.5, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7001', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('105.50', $payload['gross_amount']); + TinyAssert::same('100.00', $payload['net_amount']); + TinyAssert::same('5.50', $payload['tax_amount']); + TinyAssert::same('0.055', $payload['line_items'][0]['tax_rate']); + } + + private static function testGetTwoNewOrderDataIncludesGiftWrappingLine(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7002] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-7002', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7201] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7202] = StubStore::$addresses[7201]; + + $cart = new Cart(7002); + $cart->id_customer = 7002; + $cart->id_currency = 978; + $cart->id_address_invoice = 7201; + $cart->id_address_delivery = 7202; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7002] = [[ + 'id_product' => 9302, + 'link_rewrite' => 'gift-product', + 'name' => 'Gift Product', + 'description_short' => 'Gift product test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9302] = [['name' => 'Gifts']]; + StubStore::$images[9302] = ['id_image' => 9302]; + StubStore::$cartTotals[7002] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 123.42, + Cart::ONLY_WRAPPING => 2.42, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 102.00, + Cart::ONLY_WRAPPING => 2.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7002', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('123.42', $payload['gross_amount']); + TinyAssert::same('102.00', $payload['net_amount']); + TinyAssert::same('21.42', $payload['tax_amount']); + + $hasWrappingLine = false; + foreach ($payload['line_items'] as $lineItem) { + if ((string) ($lineItem['name'] ?? '') === 'Gift wrapping') { + $hasWrappingLine = true; + TinyAssert::same('2.42', (string) $lineItem['gross_amount']); + TinyAssert::same('2.00', (string) $lineItem['net_amount']); + TinyAssert::same('0.42', (string) $lineItem['tax_amount']); + } + } + + TinyAssert::true($hasWrappingLine); + } + + private static function testGetTwoNewOrderDataSnapsGiftWrappingRateToCanonicalContext(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$customers[7003] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Lena', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-7003', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7301] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7302] = StubStore::$addresses[7301]; + + $cart = new Cart(7003); + $cart->id_customer = 7003; + $cart->id_currency = 978; + $cart->id_address_invoice = 7301; + $cart->id_address_delivery = 7302; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[7003] = [[ + 'id_product' => 9303, + 'link_rewrite' => 'gift-product-canonical', + 'name' => 'Gift Product Canonical', + 'description_short' => 'Gift product test', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9303] = [['name' => 'Gifts']]; + StubStore::$images[9303] = ['id_image' => 9303]; + StubStore::$cartTotals[7003] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 123.99, + Cart::ONLY_WRAPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.0, + Cart::BOTH => 102.47, + Cart::ONLY_WRAPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-7003', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $wrappingLine = null; + foreach ($payload['line_items'] as $lineItem) { + if ((string)($lineItem['name'] ?? '') === 'Gift wrapping') { + $wrappingLine = $lineItem; + break; + } + } + + TinyAssert::true(is_array($wrappingLine), 'Expected gift wrapping line'); + TinyAssert::same('2.99', (string)$wrappingLine['gross_amount']); + TinyAssert::same('2.47', (string)$wrappingLine['net_amount']); + TinyAssert::same('0.52', (string)$wrappingLine['tax_amount']); + TinyAssert::same('0.21', (string)$wrappingLine['tax_rate']); + } + + private static function testGetTwoProductItemsSnapsMinorRateDriftToKnownProductContexts(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new Cart(12); + $cart->id_lang = 1; + $cart->id_carrier = 0; + + StubStore::$cartProducts[12] = [ + [ + 'id_product' => 8801, + 'link_rewrite' => 'anchor-product', + 'name' => 'Anchor Product', + 'description_short' => 'Anchor', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ], + [ + 'id_product' => 8802, + 'link_rewrite' => 'drift-product', + 'name' => 'Drift Product', + 'description_short' => 'Drift', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 2.47, + 'total_wt' => 2.99, + 'cart_quantity' => 1, + // Intentionally missing rate field to simulate amount-derived drift. + 'price' => 2.47, + 'reduction' => 0, + ], + ]; + + StubStore::$productCategories[8801] = [['name' => 'General']]; + StubStore::$productCategories[8802] = [['name' => 'General']]; + StubStore::$images[8801] = ['id_image' => 8801]; + StubStore::$images[8802] = ['id_image' => 8802]; + + $items = $module->getTwoProductItems($cart); + + TinyAssert::count(2, $items); + TinyAssert::same('0.21', (string)$items[0]['tax_rate']); + TinyAssert::same('0.21', (string)$items[1]['tax_rate']); + } + + private static function testGetTwoProductItemsSpanishFallbackDefaultsToTwentyOnePercentWithoutKnownContext(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7401] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[7402] = StubStore::$addresses[7401]; + + $cart = new Cart(14); + $cart->id_lang = 1; + $cart->id_carrier = 0; + $cart->id_address_invoice = 7401; + $cart->id_address_delivery = 7402; + + StubStore::$cartProducts[14] = [[ + 'id_product' => 8811, + 'link_rewrite' => 'es-fallback-product', + 'name' => 'ES fallback product', + 'description_short' => 'Fallback', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 2.47, + 'total_wt' => 2.99, + 'cart_quantity' => 1, + // Intentionally no rate field to force amount-derived unresolved context. + 'price' => 2.47, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8811] = [['name' => 'General']]; + StubStore::$images[8811] = ['id_image' => 8811]; + + $items = $module->getTwoProductItems($cart); + + TinyAssert::count(1, $items); + TinyAssert::same('0.21', (string)$items[0]['tax_rate']); + } + + private static function testGetTwoNewOrderDataOmitsTopLevelTaxRate(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[301] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Juan', + 'lastname' => 'Gonzalez', + 'secure_key' => 'secure-key', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[401] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[402] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(55); + $cart->id_customer = 301; + $cart->id_currency = 978; + $cart->id_address_invoice = 401; + $cart->id_address_delivery = 402; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[55] = [['id_product' => 501, 'cart_quantity' => 1]]; + StubStore::$cartTotals[55] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-55', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::false(isset($payload['tax_rate'])); + TinyAssert::true(isset($payload['tax_subtotals'])); + TinyAssert::same('100.00', $payload['net_amount']); + TinyAssert::same('20.50', $payload['tax_amount']); + TinyAssert::same('120.50', $payload['gross_amount']); + } + + private static function testGetTwoNewOrderDataOmitsTaxSubtotalsWhenDisabled(): void + { + self::reset(); + StubStore::$configuration['PS_TWO_ENABLE_TAX_SUBTOTALS'] = 0; + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[401] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Juan', + 'lastname' => 'Gonzalez', + 'secure_key' => 'secure-key', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[601] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[602] = StubStore::$addresses[601]; + + $cart = new Cart(155); + $cart->id_customer = 401; + $cart->id_currency = 978; + $cart->id_address_invoice = 601; + $cart->id_address_delivery = 602; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[155] = [['id_product' => 601, 'cart_quantity' => 1]]; + StubStore::$cartTotals[155] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-155', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::false(isset($payload['tax_subtotals'])); + TinyAssert::false(isset($payload['tax_rate'])); + } + + private static function testGetTwoIntentOrderDataOmitsTopLevelTaxRateAndOmitsTaxSubtotalsWhenDisabled(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Test', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '20.50', + 'tax_class_name' => 'VAT 20.50%', + 'tax_rate' => '0.205', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'Brand', 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + }; + + StubStore::$customers[402] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-intent', + 'loaded' => true, + ]; + StubStore::$currencies[840] = ['iso_code' => 'USD', 'loaded' => true]; + StubStore::$addresses[603] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(156); + $cart->id_customer = 402; + $cart->id_currency = 840; + $cart->id_address_invoice = 603; + $cart->id_address_delivery = 603; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[156] = [['id_product' => 602, 'cart_quantity' => 1]]; + StubStore::$cartTotals[156] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + $customer = new Customer(402); + $currency = new Currency(840); + $address = new Address(603); + + $payloadWithSubtotals = $module->getTwoIntentOrderData($cart, $customer, $currency, $address); + TinyAssert::false(isset($payloadWithSubtotals['tax_rate'])); + TinyAssert::true(isset($payloadWithSubtotals['tax_subtotals'])); + TinyAssert::true(isset($payloadWithSubtotals['billing_address'])); + TinyAssert::true(isset($payloadWithSubtotals['shipping_address'])); + TinyAssert::same('ES', $payloadWithSubtotals['billing_address']['country']); + TinyAssert::same('ES', $payloadWithSubtotals['shipping_address']['country']); + + StubStore::$configuration['PS_TWO_ENABLE_TAX_SUBTOTALS'] = 0; + $payloadWithoutSubtotals = $module->getTwoIntentOrderData($cart, $customer, $currency, $address); + TinyAssert::false(isset($payloadWithoutSubtotals['tax_rate'])); + TinyAssert::false(isset($payloadWithoutSubtotals['tax_subtotals'])); + } + + private static function testGetTwoNewOrderDataThrowsWhenLineItemsFailFormulaValidation(): void + { + self::reset(); + + $module = new class extends TwopaymentTestHarness { + public function getTwoProductItems($cart) + { + return [[ + 'name' => 'Broken line', + 'net_amount' => '100.00', + 'tax_amount' => '10.00', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'discount_amount' => '0.00', + ]]; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[302] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Maria', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-2', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[501] = [ + 'id_country' => 34, + 'company' => 'Acme S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[502] = StubStore::$addresses[501]; + + $cart = new Cart(56); + $cart->id_customer = 302; + $cart->id_currency = 978; + $cart->id_address_invoice = 501; + $cart->id_address_delivery = 502; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[56] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[56] = [ + true => [Cart::ONLY_DISCOUNTS => 0.0], + false => [Cart::ONLY_DISCOUNTS => 0.0], + 'average_products_tax_rate' => 21.0, + ]; + + TinyAssert::throws(function () use ($module, $cart): void { + $module->getTwoNewOrderData('merchant-attempt-56', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + }, 'Invalid line item formulas'); + } + + private static function testGetTwoNewOrderDataThrowsWhenCartTotalsMismatchIsMaterial(): void + { + self::reset(); + + $lineItems = [ + [ + 'name' => 'TV LG 4K UHD', + 'description' => 'Product', + 'gross_amount' => '1609.76', + 'net_amount' => '1330.38', + 'discount_amount' => '0.00', + 'tax_amount' => '279.38', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.21', + 'unit_price' => '665.19', + 'quantity' => 2, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => 'LG', 'barcodes' => [], 'categories' => []], + ], + [ + 'name' => 'Envio gratis (+1 mas)', + 'description' => 'Discount', + 'gross_amount' => '-137.90', + 'net_amount' => '-114.81', + 'discount_amount' => '0.00', + 'tax_amount' => '-23.09', + 'tax_class_name' => 'VAT 20.11%', + 'tax_rate' => '0.2011', + 'unit_price' => '-114.81', + 'quantity' => 1, + 'quantity_unit' => 'item', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'DIGITAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ], + ]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[490] = [ + 'email' => 'support@two.inc', + 'firstname' => 'two', + 'lastname' => 'support', + 'secure_key' => 'secure-key-reconcile', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[910] = [ + 'id_country' => 34, + 'company' => 'ORDER IN TECH', + 'companyid' => 'B01588177', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[911] = StubStore::$addresses[910]; + + $cart = new Cart(490); + $cart->id_customer = 490; + $cart->id_currency = 978; + $cart->id_address_invoice = 910; + $cart->id_address_delivery = 911; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[490] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[490] = [ + true => [ + Cart::ONLY_DISCOUNTS => 137.90, + Cart::BOTH => 1518.10, + ], + false => [ + Cart::ONLY_DISCOUNTS => 114.81, + Cart::BOTH => 1254.63, + ], + 'average_products_tax_rate' => 21.0, + ]; + + TinyAssert::throws(function () use ($module, $cart): void { + $module->getTwoNewOrderData('merchant-attempt-reconcile', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + }, 'Order totals do not reconcile with cart totals'); + } + + private static function testGetTwoIntentOrderDataContinuesWhenCartTotalsDoNotReconcile(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Product', + 'gross_amount' => '121.00', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '21.00', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.210000', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + }; + + StubStore::$customers[591] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-591', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[990] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(591); + $cart->id_customer = 591; + $cart->id_currency = 978; + $cart->id_address_invoice = 990; + $cart->id_address_delivery = 990; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[591] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[591] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 121.10, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $customer = new Customer(591); + $currency = new Currency(978); + $address = new Address(990); + + $payload = $module->getTwoIntentOrderData($cart, $customer, $currency, $address); + TinyAssert::same('121.00', $payload['gross_amount'], 'Expected order intent payload to be built even when cart drift exceeds create-order tolerance'); + } + + private static function testGetTwoNewOrderDataAllowsTwoCentReconciliationDrift(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Product', + 'gross_amount' => '121.00', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '21.00', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.210000', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[592] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-592', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[992] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[993] = StubStore::$addresses[992]; + + $cart = new Cart(592); + $cart->id_customer = 592; + $cart->id_currency = 978; + $cart->id_address_invoice = 992; + $cart->id_address_delivery = 993; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[592] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[592] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 121.02, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-592', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('121.00', $payload['gross_amount'], 'Expected payload to be built when drift is within 2 cents'); + TinyAssert::same('21.00', $payload['tax_amount'], 'Expected line-derived tax total to remain unchanged'); + } + + private static function testGetTwoNewOrderDataBlocksThreeCentReconciliationDrift(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Product', + 'gross_amount' => '121.00', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '21.00', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.210000', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[593] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-593', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[994] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[995] = StubStore::$addresses[994]; + + $cart = new Cart(593); + $cart->id_customer = 593; + $cart->id_currency = 978; + $cart->id_address_invoice = 994; + $cart->id_address_delivery = 995; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[593] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[593] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 121.03, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + TinyAssert::throws(function () use ($module, $cart): void { + $module->getTwoNewOrderData('merchant-attempt-593', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + }, 'Order totals do not reconcile with cart totals'); + } + + private static function testGetTwoNewOrderDataAllowsTwoCentBoundaryForLargeTotals(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Large Ticket Item', + 'description' => 'Product', + 'gross_amount' => '8145.11', + 'net_amount' => '6736.22', + 'discount_amount' => '0.00', + 'tax_amount' => '1408.89', + 'tax_class_name' => 'VAT 20.92%', + 'tax_rate' => '0.209152', + 'unit_price' => '6736.22', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function buildTermsPayload() + { + return ['type' => 'NET_TERMS', 'duration_days' => 30]; + } + }; + + StubStore::$customers[594] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-594', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[996] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[997] = StubStore::$addresses[996]; + + $cart = new Cart(594); + $cart->id_customer = 594; + $cart->id_currency = 978; + $cart->id_address_invoice = 996; + $cart->id_address_delivery = 997; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[594] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[594] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 8145.13, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 6736.22, + ], + 'average_products_tax_rate' => 20.9152, + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-594', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('8145.11', $payload['gross_amount'], 'Expected payload build at exact 2-cent boundary for large totals'); + } + + private static function testGetTwoNewOrderDataIncludesShippingAndDiscountLineItemsWhenReconciled(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[491] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Juan', + 'lastname' => 'Gonzalez', + 'secure_key' => 'secure-key-491', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[920] = [ + 'id_country' => 34, + 'company' => 'ORDER IN TECH', + 'companyid' => 'B01588177', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[921] = StubStore::$addresses[920]; + + StubStore::$carriers[31] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(491); + $cart->id_customer = 491; + $cart->id_currency = 978; + $cart->id_address_invoice = 920; + $cart->id_address_delivery = 921; + $cart->id_carrier = 31; + $cart->id_lang = 1; + + StubStore::$cartProducts[491] = [[ + 'id_product' => 777, + 'link_rewrite' => 'tv-lg', + 'name' => 'TV LG 4K UHD', + 'description_short' => 'TV', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 1320.66, + 'total_wt' => 1598.00, + 'cart_quantity' => 2, + 'rate' => 21.0, + 'price' => 660.33, + 'reduction' => 0, + ]]; + StubStore::$productCategories[777] = [['name' => 'TV']]; + StubStore::$images[777] = ['id_image' => 9901]; + StubStore::$cartShipping[491] = [ + true => 58.00, + false => 47.93, + ]; + StubStore::$cartTotals[491] = [ + true => [ + Cart::ONLY_DISCOUNTS => 137.90, + Cart::BOTH => 1518.10, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 113.96, + Cart::BOTH => 1254.63, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[491] = [ + ['name' => 'Envio gratis', 'code' => '', 'value' => -58.00, 'reduction_percent' => 0, 'reduction_amount' => 58.00, 'free_shipping' => 1], + ['name' => 'Promo cruzada| 5%', 'code' => '', 'value' => -79.90, 'reduction_percent' => 5, 'reduction_amount' => 79.90, 'free_shipping' => 0], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-491', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $lineItems = $payload['line_items']; + TinyAssert::true(count($lineItems) >= 3, 'Expected product + shipping + discount lines'); + + $hasShipping = false; + $hasDiscount = false; + $lineGross = 0.0; + foreach ($lineItems as $line) { + if (isset($line['type']) && $line['type'] === 'SHIPPING_FEE') { + $hasShipping = true; + } + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $hasDiscount = true; + } + $lineGross = round($lineGross + (float)$line['gross_amount'], 2); + } + + TinyAssert::true($hasShipping, 'Expected SHIPPING_FEE line item'); + TinyAssert::true($hasDiscount, 'Expected negative discount line item'); + TinyAssert::same('1518.10', $payload['gross_amount'], 'Expected reconciled gross total'); + TinyAssert::same(1518.10, $lineGross, 'Expected line item gross sum to match order gross'); + } + + private static function testGetTwoNewOrderDataFallbackFreeShippingUsesShippingTaxContext(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$customers[494] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Sara', + 'lastname' => 'Iglesias', + 'secure_key' => 'secure-key-494', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[942] = [ + 'id_country' => 34, + 'company' => 'Fallback Shop S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[943] = StubStore::$addresses[942]; + StubStore::$countries[34] = 'ES'; + + StubStore::$carriers[34] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(494); + $cart->id_customer = 494; + $cart->id_currency = 978; + $cart->id_address_invoice = 942; + $cart->id_address_delivery = 943; + $cart->id_carrier = 34; + $cart->id_lang = 1; + + StubStore::$cartProducts[494] = [ + [ + 'id_product' => 779, + 'link_rewrite' => 'zero-tax-item', + 'name' => 'Zero Tax Item', + 'description_short' => 'Zero', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 100.00, + 'cart_quantity' => 1, + 'rate' => 0.0, + 'price' => 100.00, + 'reduction' => 0, + ], + [ + 'id_product' => 780, + 'link_rewrite' => 'taxed-item', + 'name' => 'Taxed Item', + 'description_short' => 'Taxed', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 200.00, + 'total_wt' => 242.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 200.00, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[779] = [['name' => 'General']]; + StubStore::$productCategories[780] = [['name' => 'General']]; + StubStore::$images[779] = ['id_image' => 9903]; + StubStore::$images[780] = ['id_image' => 9904]; + StubStore::$cartShipping[494] = [ + true => 116.00, + false => 95.87, + ]; + StubStore::$cartTotals[494] = [ + true => [ + Cart::ONLY_DISCOUNTS => 116.00, + Cart::BOTH => 342.00, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 95.87, + Cart::BOTH => 300.00, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + // Intentionally omit rule-level tax-excluded metadata to force fallback path. + StubStore::$cartRules[494] = [ + ['name' => 'free shipping rule', 'code' => 'free-ship', 'value' => -116.00, 'reduction_amount' => 116.00, 'free_shipping' => 1], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-494', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLines = []; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLines[] = $line; + } + } + + TinyAssert::count(1, $discountLines); + TinyAssert::same('-116.00', (string)$discountLines[0]['gross_amount']); + TinyAssert::same('-95.87', (string)$discountLines[0]['net_amount']); + TinyAssert::same('-20.13', (string)$discountLines[0]['tax_amount']); + TinyAssert::same('0.21', (string)$discountLines[0]['tax_rate']); + } + + private static function testGetTwoNewOrderDataKeepsDiscountTaxFormulaForLargeRoundedDiscounts(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[492] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Lopez', + 'secure_key' => 'secure-key-492', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[930] = [ + 'id_country' => 34, + 'company' => 'ORDER IN TECH', + 'companyid' => 'B01588177', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + StubStore::$addresses[931] = StubStore::$addresses[930]; + + StubStore::$carriers[32] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(492); + $cart->id_customer = 492; + $cart->id_currency = 978; + $cart->id_address_invoice = 930; + $cart->id_address_delivery = 931; + $cart->id_carrier = 32; + $cart->id_lang = 1; + + StubStore::$cartProducts[492] = [[ + 'id_product' => 778, + 'link_rewrite' => 'bulk-product', + 'name' => 'Bulk Product', + 'description_short' => 'Bulk', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 3000.00, + 'total_wt' => 3630.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 3000.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[778] = [['name' => 'Bulk']]; + StubStore::$images[778] = ['id_image' => 9902]; + StubStore::$cartShipping[492] = [ + true => 121.00, + false => 100.00, + ]; + StubStore::$cartTotals[492] = [ + true => [ + Cart::ONLY_DISCOUNTS => 709.25, + Cart::BOTH => 3041.75, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 586.25, + Cart::BOTH => 2513.75, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[492] = [ + ['name' => 'free shipping rule', 'code' => '', 'value' => -121.00, 'reduction_percent' => 0, 'reduction_amount' => 121.00, 'free_shipping' => 1], + ['name' => 'bulk promo', 'code' => '', 'value' => -588.25, 'reduction_percent' => 0, 'reduction_amount' => 588.25, 'free_shipping' => 0], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-492', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLine = null; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLine = $line; + break; + } + } + + TinyAssert::true($discountLine !== null, 'Expected a discount line item'); + $lineTax = (float)$discountLine['tax_amount']; + $lineNet = (float)$discountLine['net_amount']; + $lineRate = (float)$discountLine['tax_rate']; + $diff = abs($lineTax - ($lineNet * $lineRate)); + TinyAssert::true($diff <= 0.02, 'Expected discount line tax formula to remain within tolerance, diff=' . $diff); + } + + private static function testGetTwoNewOrderDataUsesCartRuleMonetaryValuesForDiscountLines(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[493] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'John', + 'lastname' => 'Jones', + 'secure_key' => 'secure-key-493', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[940] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'J13936695', + 'address1' => 'Billing here CALLE DALIA, 10 215', + 'city' => 'BENALMADENA', + 'postcode' => '29639', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[941] = [ + 'id_country' => 826, + 'company' => 'SPAIN LTD', + 'companyid' => '06922947', + 'address1' => 'Shipping here 20-22 Wenlock Road', + 'city' => 'London', + 'postcode' => 'N1 7GU', + 'phone' => '666666668', + 'loaded' => true, + ]; + + StubStore::$carriers[33] = [ + 'name' => 'My carrier', + 'delay' => 'Delivery next day!', + 'shipping_method' => Carrier::SHIPPING_METHOD_WEIGHT, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(493); + $cart->id_customer = 493; + $cart->id_currency = 978; + $cart->id_address_invoice = 940; + $cart->id_address_delivery = 941; + $cart->id_carrier = 33; + $cart->id_lang = 1; + + StubStore::$cartProducts[493] = [ + [ + 'id_product' => 8201, + 'link_rewrite' => 'hummingbird-printed-sweater', + 'name' => 'Hummingbird printed sweater', + 'description_short' => 'Sweater', + 'manufacturer_name' => 'Studio Design', + 'ean13' => '', + 'upc' => '', + 'total' => 430.80, + 'total_wt' => 430.80, + 'cart_quantity' => 12, + 'rate' => 0.0, + 'price' => 35.90, + 'reduction' => 0, + ], + [ + 'id_product' => 8202, + 'link_rewrite' => 'hummingbird-notebook', + 'name' => 'Hummingbird notebook', + 'description_short' => 'Notebook', + 'manufacturer_name' => 'Graphic Corner', + 'ean13' => '', + 'upc' => '', + 'total' => 10832.23, + 'total_wt' => 13107.00, + 'cart_quantity' => 17, + 'rate' => 21.0, + 'price' => 637.19, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[8201] = [['name' => 'Women']]; + StubStore::$productCategories[8202] = [['name' => 'Stationery']]; + StubStore::$images[8201] = ['id_image' => 8201]; + StubStore::$images[8202] = ['id_image' => 8202]; + StubStore::$cartShipping[493] = [ + true => 116.00, + false => 95.87, + ]; + StubStore::$cartTotals[493] = [ + true => [ + Cart::ONLY_DISCOUNTS => 731.99, + Cart::BOTH => 13138.40, + Cart::ONLY_SHIPPING => 0.00, + Cart::ONLY_WRAPPING => 216.59, + ], + false => [ + Cart::ONLY_DISCOUNTS => 608.99, + Cart::BOTH => 10928.91, + Cart::ONLY_SHIPPING => 0.00, + Cart::ONLY_WRAPPING => 179.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[493] = [ + [ + 'name' => 'free shipping rule', + 'code' => 'free-ship', + 'value' => -58.00, + 'value_real' => 58.00, + 'value_tax_exc' => 48.252911813643927, + ], + [ + 'name' => 'discount-rule', + 'code' => 'discount-rule', + 'value' => -673.99, + 'value_real' => 673.99, + 'value_tax_exc' => 560.73885440931781, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-493', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLines = []; + foreach ($payload['line_items'] as $line) { + if (isset($line['gross_amount']) && (float)$line['gross_amount'] < 0) { + $discountLines[] = $line; + } + } + + TinyAssert::true(count($discountLines) >= 2, 'Expected discount lines to be represented in payload'); + + $aggregatedByRule = []; + foreach ($discountLines as $line) { + $lineName = (string)$line['name']; + $baseName = preg_replace('/\s+\(VAT\s+[^)]+\)$/', '', $lineName); + if (!isset($aggregatedByRule[$baseName])) { + $aggregatedByRule[$baseName] = ['net' => 0.0, 'gross' => 0.0]; + } + $aggregatedByRule[$baseName]['net'] += (float)$line['net_amount']; + $aggregatedByRule[$baseName]['gross'] += (float)$line['gross_amount']; + + // Discount rates must stay on canonical cart tax contexts for provider compatibility. + $lineRate = (float)$line['tax_rate']; + $isCanonicalRate = abs($lineRate - 0.0) <= 0.000001 || abs($lineRate - 0.21) <= 0.000001; + TinyAssert::true($isCanonicalRate, 'Expected discount tax_rate to stay on canonical contexts (0 or 0.21), got: ' . $line['tax_rate']); + } + + TinyAssert::true(isset($aggregatedByRule['free shipping rule']), 'Expected free shipping rule discount line'); + TinyAssert::true(isset($aggregatedByRule['discount-rule']), 'Expected discount-rule discount line'); + TinyAssert::same('-48.25', number_format($aggregatedByRule['free shipping rule']['net'], 2, '.', ''), 'Expected canonical net discount from cart rule value_tax_exc'); + TinyAssert::same('-560.74', number_format($aggregatedByRule['discount-rule']['net'], 2, '.', ''), 'Expected canonical net discount from cart rule value_tax_exc'); + TinyAssert::same('-58.00', number_format($aggregatedByRule['free shipping rule']['gross'], 2, '.', ''), 'Expected canonical gross discount from cart rule value_real'); + TinyAssert::same('-673.99', number_format($aggregatedByRule['discount-rule']['gross'], 2, '.', ''), 'Expected canonical gross discount from cart rule value_real'); + } + + private static function testGetTwoNewOrderDataHandlesMixedCartRuleMetadataWithPartialFallback(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[497] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Marta', + 'lastname' => 'Perez', + 'secure_key' => 'secure-key-497', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[952] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[953] = StubStore::$addresses[952]; + + $cart = new Cart(497); + $cart->id_customer = 497; + $cart->id_currency = 978; + $cart->id_address_invoice = 952; + $cart->id_address_delivery = 953; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[497] = [[ + 'id_product' => 8302, + 'link_rewrite' => 'single-taxed-product-2', + 'name' => 'Single taxed product 2', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8302] = [['name' => 'General']]; + StubStore::$images[8302] = ['id_image' => 8302]; + StubStore::$cartShipping[497] = [true => 0.00, false => 0.00]; + StubStore::$cartTotals[497] = [ + true => [ + Cart::ONLY_DISCOUNTS => 30.00, + Cart::BOTH => 91.00, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 24.79, + Cart::BOTH => 75.21, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[497] = [ + [ + 'name' => 'fixed-10', + 'code' => 'fixed-10', + 'value' => -10.00, + 'value_real' => 10.00, + 'value_tax_exc' => 8.26, + ], + [ + 'name' => 'percent-metadata-missing', + 'code' => 'percent-metadata-missing', + 'value' => -20.00, + 'value_real' => 20.00, + // Missing net metadata should fallback only for unresolved remainder. + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-497', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLines = []; + $discountGross = 0.0; + $fixedTenGross = 0.0; + $fixedTenNet = 0.0; + $fixedTenRates = []; + foreach ($payload['line_items'] as $line) { + if (!isset($line['gross_amount']) || (float)$line['gross_amount'] >= 0) { + continue; + } + $discountLines[] = $line; + $discountGross = round($discountGross + (float)$line['gross_amount'], 2); + if ((string)$line['name'] === 'fixed-10') { + $fixedTenGross = round($fixedTenGross + (float)$line['gross_amount'], 2); + $fixedTenNet = round($fixedTenNet + (float)$line['net_amount'], 2); + $fixedTenRates[] = (string)$line['tax_rate']; + } + } + + TinyAssert::true(count($discountLines) >= 2, 'Expected mixed cart-rule metadata to produce rule-scoped + fallback discount lines'); + TinyAssert::same('-10.00', number_format($fixedTenGross, 2, '.', ''), 'Expected complete fixed rule gross to remain canonical'); + TinyAssert::same('-8.26', number_format($fixedTenNet, 2, '.', ''), 'Expected complete fixed rule net to remain canonical'); + TinyAssert::same('0.21', (string)reset($fixedTenRates), 'Expected complete fixed rule tax rate to remain canonical'); + TinyAssert::same('-30.00', number_format($discountGross, 2, '.', ''), 'Expected total discount gross to remain fully reconciled'); + } + + private static function testGetTwoNewOrderDataMixedMetadataKeepsCompleteRuleValuesAndFreeShippingContext(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[596] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Nora', + 'lastname' => 'Vega', + 'secure_key' => 'secure-key-596', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[996] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Norte 1', + 'city' => 'Madrid', + 'postcode' => '28006', + 'phone' => '666666673', + 'loaded' => true, + ]; + StubStore::$addresses[997] = StubStore::$addresses[996]; + StubStore::$carriers[596] = [ + 'name' => 'Carrier 596', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 96, + ]; + StubStore::$taxRuleRates[96] = 21.0; + + $cart = new Cart(596); + $cart->id_customer = 596; + $cart->id_currency = 978; + $cart->id_address_invoice = 996; + $cart->id_address_delivery = 997; + $cart->id_carrier = 596; + $cart->id_lang = 1; + + StubStore::$cartProducts[596] = [[ + 'id_product' => 8396, + 'link_rewrite' => 'mixed-free-shipping-case', + 'name' => 'Mixed free shipping case', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 110.00, + 'cart_quantity' => 1, + 'rate' => 10.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8396] = [['name' => 'General']]; + StubStore::$images[8396] = ['id_image' => 8396]; + StubStore::$cartShipping[596] = [true => 12.10, false => 10.00]; + StubStore::$cartTotals[596] = [ + true => [ + Cart::ONLY_DISCOUNTS => 23.10, + Cart::BOTH => 99.00, + Cart::ONLY_SHIPPING => 12.10, + ], + false => [ + Cart::ONLY_DISCOUNTS => 20.00, + Cart::BOTH => 90.00, + Cart::ONLY_SHIPPING => 10.00, + ], + 'average_products_tax_rate' => 10.0, + ]; + StubStore::$cartRules[596] = [ + [ + 'name' => 'fixed-voucher-11', + 'code' => 'fixed-voucher-11', + 'value' => -11.00, + 'value_real' => 11.00, + 'value_tax_exc' => 10.00, + 'free_shipping' => 0, + ], + [ + 'name' => 'free-shipping-missing-net', + 'code' => 'free-shipping-missing-net', + 'value' => -12.10, + 'value_real' => 12.10, + 'free_shipping' => 1, + // Missing value_tax_exc should fallback on shipping context only. + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-596', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $fixedGross = 0.0; + $fixedNet = 0.0; + $freeShippingGross = 0.0; + $freeShippingNet = 0.0; + $freeShippingRate = ''; + $discountGross = 0.0; + foreach ($payload['line_items'] as $line) { + if (!isset($line['gross_amount']) || (float)$line['gross_amount'] >= 0) { + continue; + } + $discountGross = round($discountGross + (float)$line['gross_amount'], 2); + + $lineName = (string)$line['name']; + if (strpos($lineName, 'fixed-voucher-11') === 0) { + $fixedGross = round($fixedGross + (float)$line['gross_amount'], 2); + $fixedNet = round($fixedNet + (float)$line['net_amount'], 2); + } + if (strpos($lineName, 'free-shipping-missing-net') === 0) { + $freeShippingGross = round($freeShippingGross + (float)$line['gross_amount'], 2); + $freeShippingNet = round($freeShippingNet + (float)$line['net_amount'], 2); + $freeShippingRate = (string)$line['tax_rate']; + } + } + + TinyAssert::same('-11.00', number_format($fixedGross, 2, '.', ''), 'Expected complete non-shipping rule gross to remain canonical'); + TinyAssert::same('-10.00', number_format($fixedNet, 2, '.', ''), 'Expected complete non-shipping rule net to remain canonical'); + TinyAssert::same('-12.10', number_format($freeShippingGross, 2, '.', ''), 'Expected unresolved free-shipping gross to stay on shipping context'); + TinyAssert::same('-10.00', number_format($freeShippingNet, 2, '.', ''), 'Expected unresolved free-shipping net to stay on shipping context'); + TinyAssert::same('0.21', $freeShippingRate, 'Expected unresolved free-shipping rate to follow shipping VAT context'); + TinyAssert::same('-23.10', number_format($discountGross, 2, '.', ''), 'Expected total discount gross to remain reconciled'); + } + + private static function testGetTwoNewOrderDataSpanishOddDecimalsKeepCanonicalTwentyOneDiscountRates(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[597] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Marin', + 'secure_key' => 'secure-key-597', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[998] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Uno 1', + 'city' => 'Madrid', + 'postcode' => '28007', + 'phone' => '666666674', + 'loaded' => true, + ]; + StubStore::$addresses[999] = StubStore::$addresses[998]; + StubStore::$carriers[597] = [ + 'name' => 'Carrier 597', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 97, + ]; + StubStore::$taxRuleRates[97] = 21.0; + + $cart = new Cart(597); + $cart->id_customer = 597; + $cart->id_currency = 978; + $cart->id_address_invoice = 998; + $cart->id_address_delivery = 999; + $cart->id_carrier = 597; + $cart->id_lang = 1; + + StubStore::$cartProducts[597] = [[ + 'id_product' => 8397, + 'link_rewrite' => 'odd-decimal-es-product', + 'name' => 'Odd decimal ES product', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 551.83, + 'total_wt' => 667.72, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 551.83, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8397] = [['name' => 'General']]; + StubStore::$images[8397] = ['id_image' => 8397]; + StubStore::$cartShipping[597] = [true => 2.99, false => 2.47]; + StubStore::$cartTotals[597] = [ + true => [ + Cart::ONLY_DISCOUNTS => 36.38, + Cart::BOTH => 634.33, + Cart::ONLY_SHIPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 30.07, + Cart::BOTH => 524.23, + Cart::ONLY_SHIPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[597] = [ + [ + 'name' => 'Envío gratis', + 'code' => 'free-shipping-es', + 'value' => -2.99, + 'value_real' => 2.99, + 'value_tax_exc' => 2.47, + 'free_shipping' => 1, + ], + [ + 'name' => 'Promo cruzada| 5%', + 'code' => 'promo-cruzada-5', + 'value' => -33.39, + 'value_real' => 33.39, + 'value_tax_exc' => 27.60, + 'free_shipping' => 0, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-597', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('634.33', (string)$payload['gross_amount']); + TinyAssert::same('524.23', (string)$payload['net_amount']); + TinyAssert::same('110.10', (string)$payload['tax_amount']); + + $aggregatedByRule = []; + foreach ($payload['line_items'] as $line) { + if (!isset($line['gross_amount']) || (float)$line['gross_amount'] >= 0) { + continue; + } + $lineName = (string)$line['name']; + $baseName = preg_replace('/\s+\(VAT\s+[^)]+\)$/', '', $lineName); + if (!isset($aggregatedByRule[$baseName])) { + $aggregatedByRule[$baseName] = ['gross' => 0.0, 'net' => 0.0]; + } + $aggregatedByRule[$baseName]['gross'] = round($aggregatedByRule[$baseName]['gross'] + (float)$line['gross_amount'], 2); + $aggregatedByRule[$baseName]['net'] = round($aggregatedByRule[$baseName]['net'] + (float)$line['net_amount'], 2); + + $lineRate = (float)$line['tax_rate']; + TinyAssert::true(abs($lineRate - 0.21) <= 0.000001, 'Expected ES discount tax rate to be canonical 0.21, got: ' . $line['tax_rate']); + } + + TinyAssert::same('-2.99', number_format($aggregatedByRule['Envío gratis']['gross'], 2, '.', '')); + TinyAssert::same('-2.47', number_format($aggregatedByRule['Envío gratis']['net'], 2, '.', '')); + TinyAssert::same('-33.39', number_format($aggregatedByRule['Promo cruzada| 5%']['gross'], 2, '.', '')); + TinyAssert::same('-27.60', number_format($aggregatedByRule['Promo cruzada| 5%']['net'], 2, '.', '')); + } + + private static function testGetTwoNewOrderDataSpanishTinyPartialFallbackKeepsCanonicalRates(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[598] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Iria', + 'lastname' => 'Paz', + 'secure_key' => 'secure-key-598', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[1000] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Dos 2', + 'city' => 'Madrid', + 'postcode' => '28008', + 'phone' => '666666675', + 'loaded' => true, + ]; + StubStore::$addresses[1001] = StubStore::$addresses[1000]; + StubStore::$carriers[598] = [ + 'name' => 'Carrier 598', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 98, + ]; + StubStore::$taxRuleRates[98] = 21.0; + + $cart = new Cart(598); + $cart->id_customer = 598; + $cart->id_currency = 978; + $cart->id_address_invoice = 1000; + $cart->id_address_delivery = 1001; + $cart->id_carrier = 598; + $cart->id_lang = 1; + + StubStore::$cartProducts[598] = [[ + 'id_product' => 8398, + 'link_rewrite' => 'tiny-partial-fallback-product', + 'name' => 'Tiny partial fallback product', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 11.55, + 'total_wt' => 13.98, + 'cart_quantity' => 7, + 'rate' => 21.0, + 'price' => 1.65, + 'reduction' => 0, + ]]; + StubStore::$productCategories[8398] = [['name' => 'General']]; + StubStore::$images[8398] = ['id_image' => 8398]; + StubStore::$cartShipping[598] = [true => 1.21, false => 1.00]; + StubStore::$cartTotals[598] = [ + true => [ + Cart::ONLY_DISCOUNTS => 1.23, + Cart::BOTH => 13.96, + Cart::ONLY_SHIPPING => 1.21, + ], + false => [ + Cart::ONLY_DISCOUNTS => 1.02, + Cart::BOTH => 11.53, + Cart::ONLY_SHIPPING => 1.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[598] = [ + [ + 'name' => 'tiny-fixed-2c', + 'code' => 'tiny-fixed-2c', + 'value' => -0.02, + 'value_real' => 0.02, + 'value_tax_exc' => 0.02, + 'free_shipping' => 0, + ], + [ + 'name' => 'tiny-free-shipping', + 'code' => 'tiny-free-shipping', + 'value' => -1.21, + 'value_real' => 1.21, + 'free_shipping' => 1, + // Missing net metadata by design. + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-598', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('13.96', (string)$payload['gross_amount']); + TinyAssert::same('11.53', (string)$payload['net_amount']); + TinyAssert::same('2.43', (string)$payload['tax_amount']); + + $tinyFixedGross = 0.0; + $tinyFixedNet = 0.0; + $tinyFreeShippingGross = 0.0; + $tinyFreeShippingNet = 0.0; + $tinyFreeShippingRate = ''; + foreach ($payload['line_items'] as $line) { + if (!isset($line['gross_amount']) || (float)$line['gross_amount'] >= 0) { + continue; + } + + $lineName = (string)$line['name']; + if (strpos($lineName, 'tiny-fixed-2c') === 0) { + $tinyFixedGross = round($tinyFixedGross + (float)$line['gross_amount'], 2); + $tinyFixedNet = round($tinyFixedNet + (float)$line['net_amount'], 2); + } + if (strpos($lineName, 'tiny-free-shipping') === 0) { + $tinyFreeShippingGross = round($tinyFreeShippingGross + (float)$line['gross_amount'], 2); + $tinyFreeShippingNet = round($tinyFreeShippingNet + (float)$line['net_amount'], 2); + $tinyFreeShippingRate = (string)$line['tax_rate']; + } + } + + TinyAssert::same('-0.02', number_format($tinyFixedGross, 2, '.', ''), 'Expected tiny complete rule gross to remain exact'); + TinyAssert::same('-0.02', number_format($tinyFixedNet, 2, '.', ''), 'Expected tiny complete rule net to remain exact'); + TinyAssert::same('-1.21', number_format($tinyFreeShippingGross, 2, '.', ''), 'Expected tiny unresolved free-shipping gross to stay on shipping context'); + TinyAssert::same('-1.00', number_format($tinyFreeShippingNet, 2, '.', ''), 'Expected tiny unresolved free-shipping net to stay on shipping context'); + TinyAssert::same('0.21', $tinyFreeShippingRate, 'Expected tiny unresolved free-shipping rate to stay canonical 0.21'); + } + + private static function testGetTwoNewOrderDataSnapsSmallDiscountRateToCanonicalContext(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[496] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Eva', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-496', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[950] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[951] = StubStore::$addresses[950]; + + $cart = new Cart(496); + $cart->id_customer = 496; + $cart->id_currency = 978; + $cart->id_address_invoice = 950; + $cart->id_address_delivery = 951; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[496] = [ + [ + 'id_product' => 8301, + 'link_rewrite' => 'single-taxed-product', + 'name' => 'Single taxed product', + 'description_short' => 'Product', + 'manufacturer_name' => 'ACME', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ], + ]; + StubStore::$productCategories[8301] = [['name' => 'General']]; + StubStore::$images[8301] = ['id_image' => 8301]; + StubStore::$cartShipping[496] = [true => 0.00, false => 0.00]; + StubStore::$cartTotals[496] = [ + true => [ + Cart::ONLY_DISCOUNTS => 4.69, + Cart::BOTH => 116.31, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 3.87, + Cart::BOTH => 96.13, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[496] = [ + [ + 'name' => 'discount-rule-1', + 'code' => 'discount-rule-1', + 'value' => -4.69, + 'value_real' => 4.69, + 'value_tax_exc' => 3.87, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-attempt-496', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + $discountLine = null; + foreach ($payload['line_items'] as $line) { + if ((string)$line['name'] === 'discount-rule-1') { + $discountLine = $line; + break; + } + } + + TinyAssert::true($discountLine !== null, 'Expected discount-rule-1 line item'); + TinyAssert::same('0.21', (string)$discountLine['tax_rate'], 'Expected small discount line to snap to canonical 0.21 rate'); + } + + private static function testMerchantCase1BuildsExpectedOrderPayload(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6101] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Uno', + 'secure_key' => 'secure-key-6101', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7101] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7102] = StubStore::$addresses[7101]; + StubStore::$carriers[710] = [ + 'name' => 'My carrier', + 'delay' => 'Delivery next day!', + 'shipping_method' => Carrier::SHIPPING_METHOD_WEIGHT, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6101); + $cart->id_customer = 6101; + $cart->id_currency = 978; + $cart->id_address_invoice = 7101; + $cart->id_address_delivery = 7102; + $cart->id_carrier = 710; + $cart->id_lang = 1; + + StubStore::$cartProducts[6101] = [[ + 'id_product' => 9101, + 'link_rewrite' => 'tv-lg-4k', + 'name' => 'TV LG 4K UHD, SmartTV con IA, 164 cm (65")', + 'description_short' => 'TV', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 1320.66, + 'total_wt' => 1598.00, + 'cart_quantity' => 2, + 'rate' => 21.0, + 'price' => 660.33, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9101] = [['name' => 'TV']]; + StubStore::$images[9101] = ['id_image' => 9101]; + StubStore::$cartShipping[6101] = [ + true => 58.00, + false => 47.93, + ]; + StubStore::$cartTotals[6101] = [ + true => [ + Cart::ONLY_DISCOUNTS => 137.90, + Cart::BOTH => 1518.10, + Cart::ONLY_SHIPPING => 0.00, + ], + false => [ + Cart::ONLY_DISCOUNTS => 113.96, + Cart::BOTH => 1254.63, + Cart::ONLY_SHIPPING => 0.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + StubStore::$cartRules[6101] = [ + [ + 'name' => 'Envío gratis', + 'code' => 'free-shipping', + 'value' => -58.00, + 'value_real' => 58.00, + 'value_tax_exc' => 47.93, + 'free_shipping' => 1, + ], + [ + 'name' => 'Promo cruzada| 5%', + 'code' => 'cross-promo', + 'value' => -79.90, + 'value_real' => 79.90, + 'value_tax_exc' => 66.03, + 'free_shipping' => 0, + ], + ]; + + $payload = $module->getTwoNewOrderData('merchant-case-1', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('1518.10', (string)$payload['gross_amount']); + TinyAssert::same('1254.63', (string)$payload['net_amount']); + TinyAssert::same('263.47', (string)$payload['tax_amount']); + + $shippingSeen = false; + $freeShippingDiscountSeen = false; + $promoDiscountSeen = false; + foreach ($payload['line_items'] as $line) { + if ((string)$line['type'] === 'SHIPPING_FEE') { + $shippingSeen = true; + TinyAssert::same('58.00', (string)$line['gross_amount']); + } + if ((string)$line['name'] === 'Envío gratis') { + $freeShippingDiscountSeen = true; + TinyAssert::same('-58.00', (string)$line['gross_amount']); + } + if ((string)$line['name'] === 'Promo cruzada| 5%') { + $promoDiscountSeen = true; + TinyAssert::same('-79.90', (string)$line['gross_amount']); + } + } + TinyAssert::true($shippingSeen, 'Expected shipping line'); + TinyAssert::true($freeShippingDiscountSeen, 'Expected free shipping discount line'); + TinyAssert::true($promoDiscountSeen, 'Expected promo discount line'); + } + + private static function testMerchantCase2BlocksOnInconsistentOrderTotals(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6102] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Dos', + 'secure_key' => 'secure-key-6102', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7201] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7202] = StubStore::$addresses[7201]; + StubStore::$carriers[720] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6102); + $cart->id_customer = 6102; + $cart->id_currency = 978; + $cart->id_address_invoice = 7201; + $cart->id_address_delivery = 7202; + $cart->id_carrier = 720; + $cart->id_lang = 1; + + StubStore::$cartProducts[6102] = [[ + 'id_product' => 9201, + 'link_rewrite' => 'lg-projector', + 'name' => 'LG CineBeam LED Projector with SmartTV WebOS', + 'description_short' => 'Projector', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 548.53, + 'total_wt' => 663.72, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 548.53, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9201] = [['name' => 'Projectors']]; + StubStore::$images[9201] = ['id_image' => 9201]; + StubStore::$cartShipping[6102] = [ + true => 2.99, + false => 2.47, + ]; + // Intentionally inconsistent with line totals to mimic merchant case 2. + StubStore::$cartTotals[6102] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 25610.36, + Cart::ONLY_SHIPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 21165.59, + Cart::ONLY_SHIPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + TinyAssert::throws(function () use ($module, $cart) { + $module->getTwoNewOrderData('merchant-case-2', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + }, 'Order totals do not reconcile with cart totals'); + } + + private static function testMerchantCase3BuildsSimpleOrderPayload(): void + { + self::reset(); + + $module = new TwopaymentTestHarness(); + + StubStore::$customers[6103] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Cliente', + 'lastname' => 'Tres', + 'secure_key' => 'secure-key-6103', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[7301] = [ + 'id_country' => 34, + 'company' => 'SPAIN', + 'companyid' => 'E20468708', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '666666668', + 'loaded' => true, + ]; + StubStore::$addresses[7302] = StubStore::$addresses[7301]; + StubStore::$carriers[730] = [ + 'name' => 'Carrier', + 'delay' => '', + 'shipping_method' => Carrier::SHIPPING_METHOD_PRICE, + 'tax_rules_group_id' => 7, + ]; + StubStore::$taxRuleRates[7] = 21.0; + + $cart = new Cart(6103); + $cart->id_customer = 6103; + $cart->id_currency = 978; + $cart->id_address_invoice = 7301; + $cart->id_address_delivery = 7302; + $cart->id_carrier = 730; + $cart->id_lang = 1; + + StubStore::$cartProducts[6103] = [[ + 'id_product' => 9301, + 'link_rewrite' => 'lg-xboom', + 'name' => 'LG XBOOM High Voltage Speaker, 1000W', + 'description_short' => 'Speaker', + 'manufacturer_name' => 'LG', + 'ean13' => '', + 'upc' => '', + 'total' => 409.24, + 'total_wt' => 495.18, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 409.24, + 'reduction' => 0, + ]]; + StubStore::$productCategories[9301] = [['name' => 'Audio']]; + StubStore::$images[9301] = ['id_image' => 9301]; + StubStore::$cartShipping[6103] = [ + true => 2.99, + false => 2.47, + ]; + StubStore::$cartTotals[6103] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 498.17, + Cart::ONLY_SHIPPING => 2.99, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 411.71, + Cart::ONLY_SHIPPING => 2.47, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $payload = $module->getTwoNewOrderData('merchant-case-3', $cart, [ + 'merchant_confirmation_url' => 'https://shop.local/confirm', + 'merchant_cancel_order_url' => 'https://shop.local/cancel', + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '', + ]); + + TinyAssert::same('498.17', (string)$payload['gross_amount']); + TinyAssert::same('411.71', (string)$payload['net_amount']); + TinyAssert::same('86.46', (string)$payload['tax_amount']); + + $hasProduct = false; + $hasShipping = false; + foreach ($payload['line_items'] as $line) { + if ((string)$line['type'] === 'PHYSICAL') { + $hasProduct = true; + } + if ((string)$line['type'] === 'SHIPPING_FEE') { + $hasShipping = true; + TinyAssert::same('2.99', (string)$line['gross_amount']); + TinyAssert::same('0.21', (string)$line['tax_rate']); + } + } + TinyAssert::true($hasProduct, 'Expected product line'); + TinyAssert::true($hasShipping, 'Expected shipping line'); + } + + private static function testGetTwoRequestHeadersSkipApiKeyForOrderIntent(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $orderIntentHeaders = $module->getTwoRequestHeaders('/v1/order_intent'); + $orderIntentHeadersWithExtras = $module->getTwoRequestHeaders( + '/v1/order_intent', + ['Authorization: Bearer should-not-leak', 'X-API-Key: should-not-leak'] + ); + $createOrderHeaders = $module->getTwoRequestHeaders('/v1/order'); + + $orderIntentHasApiKey = false; + foreach ($orderIntentHeaders as $header) { + if (strpos($header, 'X-API-Key:') === 0) { + $orderIntentHasApiKey = true; + break; + } + } + + $orderIntentHasAuthHeaders = false; + foreach ($orderIntentHeadersWithExtras as $header) { + if ( + strpos($header, 'X-API-Key:') === 0 || + strpos($header, 'Authorization:') === 0 || + strpos($header, 'Proxy-Authorization:') === 0 + ) { + $orderIntentHasAuthHeaders = true; + break; + } + } + + $createOrderHasApiKey = false; + foreach ($createOrderHeaders as $header) { + if (strpos($header, 'X-API-Key:') === 0) { + $createOrderHasApiKey = true; + break; + } + } + + TinyAssert::false($orderIntentHasApiKey, 'Order intent headers must not include X-API-Key'); + TinyAssert::false($orderIntentHasAuthHeaders, 'Order intent headers must not include auth headers'); + TinyAssert::true($createOrderHasApiKey, 'Create order headers must include X-API-Key'); + } + + private static function testCheckTwoOrderIntentApprovalAtPaymentDeclinesEvenWhenFrontendCookieSaysApproved(): void + { + self::reset(); + + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 200, + 'approved' => false, + ]; + } + }; + + $module->context->cookie->two_order_intent_approved = '1'; + $module->context->cookie->two_order_intent_timestamp = (string) time(); + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + TinyAssert::false($result['approved'], 'Expected backend decline to override frontend cookie telemetry'); + TinyAssert::same('declined', $result['status']); + } + + private static function testCheckTwoOrderIntentApprovalAtPaymentAllowsApprovedResponse(): void + { + self::reset(); + + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 200, + 'approved' => true, + 'message' => 'ok', + ]; + } + }; + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + TinyAssert::true($result['approved']); + TinyAssert::same('approved', $result['status']); + } + + private static function testCheckTwoOrderIntentApprovalAtPaymentHandlesProviderNetworkFailure(): void + { + self::reset(); + + $module = new class extends TwopaymentTestHarness { + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return ['currency' => 'EUR']; + } + + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return false; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return [ + 'http_status' => 0, + 'error' => 'Connection error', + 'error_message' => 'Unable to connect', + ]; + } + }; + + $result = $module->checkTwoOrderIntentApprovalAtPayment(new Cart(1), new Customer(), new Currency(), new Address()); + + TinyAssert::false($result['approved']); + TinyAssert::same('provider_unavailable', $result['status']); + } + + private static function testCheckTwoOrderIntentApprovalAtPaymentBlocksOnStrictReconciliationDrift(): void + { + self::reset(); + + $lineItems = [[ + 'name' => 'Widget', + 'description' => 'Product', + 'gross_amount' => '121.00', + 'net_amount' => '100.00', + 'discount_amount' => '0.00', + 'tax_amount' => '21.00', + 'tax_class_name' => 'VAT 21.00%', + 'tax_rate' => '0.21', + 'unit_price' => '100.00', + 'quantity' => 1, + 'quantity_unit' => 'pcs', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'PHYSICAL', + 'details' => ['brand' => null, 'barcodes' => [], 'categories' => []], + ]]; + + $module = new class($lineItems) extends TwopaymentTestHarness { + private array $forcedLineItems; + public bool $providerCalled = false; + + public function __construct(array $forcedLineItems) + { + parent::__construct(); + $this->forcedLineItems = $forcedLineItems; + } + + public function getTwoProductItems($cart) + { + return $this->forcedLineItems; + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + $this->providerCalled = true; + return [ + 'http_status' => 200, + 'approved' => true, + ]; + } + }; + + StubStore::$customers[781] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Ana', + 'lastname' => 'Garcia', + 'secure_key' => 'secure-key-781', + 'loaded' => true, + ]; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$addresses[1781] = [ + 'id_country' => 34, + 'company' => 'ACME S.L.', + 'companyid' => 'B12345678', + 'address1' => 'Calle Mayor 1', + 'city' => 'Madrid', + 'postcode' => '28001', + 'phone' => '+34910000000', + 'loaded' => true, + ]; + + $cart = new Cart(781); + $cart->id_customer = 781; + $cart->id_currency = 978; + $cart->id_address_invoice = 1781; + $cart->id_address_delivery = 1781; + $cart->id_carrier = 0; + $cart->id_lang = 1; + + StubStore::$cartProducts[781] = [['id_product' => 1, 'cart_quantity' => 1]]; + StubStore::$cartTotals[781] = [ + true => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 121.20, + ], + false => [ + Cart::ONLY_DISCOUNTS => 0.00, + Cart::BOTH => 100.00, + ], + 'average_products_tax_rate' => 21.0, + ]; + + $result = $module->checkTwoOrderIntentApprovalAtPayment($cart, new Customer(781), new Currency(978), new Address(1781)); + + TinyAssert::false($result['approved']); + TinyAssert::same('reconciliation_mismatch', $result['status']); + TinyAssert::false($module->providerCalled); + } + + private static function testCreateTwoLocalOrderAfterProviderVerificationRecoversExistingOrderOnRace(): void + { + self::reset(); + + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$customers[882] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-882', + 'loaded' => true, + ]; + + $cart = new Cart(882); + $cart->id_customer = 882; + $cart->id_currency = 978; + + $module = new class extends TwopaymentTestHarness { + public function validateOrder( + $id_cart, + $id_order_state, + $amount_paid, + $payment_method = 'Unknown', + $message = null, + $extra_vars = [], + $currency_special = null, + $dont_touch_amount = false, + $secure_key = false, + ?Shop $shop = null, + ?string $order_reference = null + ) { + throw new Exception('Cart cannot be loaded or an order has already been placed using this cart'); + } + + public function getTwoOrderIdByCart($id_cart) + { + return 445; + } + }; + + $result = $module->createTwoLocalOrderAfterProviderVerification( + $cart, + new Customer(882), + 1, + 121.00 + ); + + TinyAssert::true($result['success']); + TinyAssert::same(445, (int) $result['id_order']); + TinyAssert::true($result['recovered_existing']); + } + + private static function testCreateTwoLocalOrderAfterProviderVerificationFailsWhenNoRecoverableOrderExists(): void + { + self::reset(); + + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$customers[883] = [ + 'email' => 'buyer@example.com', + 'firstname' => 'Luis', + 'lastname' => 'Ramos', + 'secure_key' => 'secure-key-883', + 'loaded' => true, + ]; + + $cart = new Cart(883); + $cart->id_customer = 883; + $cart->id_currency = 978; + + $module = new class extends TwopaymentTestHarness { + public function validateOrder( + $id_cart, + $id_order_state, + $amount_paid, + $payment_method = 'Unknown', + $message = null, + $extra_vars = [], + $currency_special = null, + $dont_touch_amount = false, + $secure_key = false, + ?Shop $shop = null, + ?string $order_reference = null + ) { + throw new Exception('cart exception'); + } + + public function getTwoOrderIdByCart($id_cart) + { + return 0; + } + }; + + $result = $module->createTwoLocalOrderAfterProviderVerification( + $cart, + new Customer(883), + 1, + 121.00 + ); + + TinyAssert::false($result['success']); + TinyAssert::same(0, (int) $result['id_order']); + TinyAssert::false($result['recovered_existing']); + } + + private static function testCancelTwoOrderBestEffortReturnsTrueOnSuccessAndFalseOnFailure(): void + { + self::reset(); + + $successModule = new class extends TwopaymentTestHarness { + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return ['http_status' => 200]; + } + }; + TinyAssert::true($successModule->cancelTwoOrderBestEffort('two-success', 'test')); + + $failureModule = new class extends TwopaymentTestHarness { + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + return ['http_status' => 500]; + } + }; + TinyAssert::false($failureModule->cancelTwoOrderBestEffort('two-failure', 'test')); + } + + private static function testExtractTwoProviderGrossAmountForValidationSupportsRootAndNestedPayloads(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::same(121.10, $module->extractTwoProviderGrossAmountForValidation(['gross_amount' => '121.10'])); + TinyAssert::same(1518.10, $module->extractTwoProviderGrossAmountForValidation(['data' => ['gross_amount' => '1518.10']])); + TinyAssert::same(null, $module->extractTwoProviderGrossAmountForValidation(['gross_amount' => ''])); + } + + private static function testSnapshotHashIgnoresTaxRateChangesBeyondTwoDecimals(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new stdClass(); + $cart->id = 77; + $cart->id_customer = 1; + $cart->id_currency = 978; + $cart->id_address_invoice = 1; + $cart->id_address_delivery = 1; + $cart->id_carrier = 0; + + $basePayload = [ + 'currency' => 'EUR', + 'gross_amount' => '120.50', + 'net_amount' => '100.00', + 'tax_amount' => '20.50', + 'discount_amount' => '0.00', + 'line_items' => [[ + 'type' => 'PHYSICAL', + 'quantity' => 1, + 'unit_price' => '100.00', + 'net_amount' => '100.00', + 'tax_amount' => '20.50', + 'gross_amount' => '120.50', + 'discount_amount' => '0.00', + 'tax_rate' => '0.205', + ]], + 'tax_subtotals' => [[ + 'tax_rate' => '0.205', + 'taxable_amount' => '100.00', + 'tax_amount' => '20.50', + ]], + ]; + + $changedPayload = $basePayload; + $changedPayload['line_items'][0]['tax_rate'] = '0.206'; + $changedPayload['tax_subtotals'][0]['tax_rate'] = '0.206'; + + $hashA = $module->calculateTwoCheckoutSnapshotHash($cart, $basePayload); + $hashB = $module->calculateTwoCheckoutSnapshotHash($cart, $changedPayload); + + TinyAssert::same($hashA, $hashB); + } + + private static function testBuildTwoOrderCreateIdempotencyKeyIsDeterministicForSameSnapshot(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new Cart(991); + $cart->id_customer = 123; + + $keyA = $module->buildTwoOrderCreateIdempotencyKey($cart, 'snapshot-hash-1'); + $keyB = $module->buildTwoOrderCreateIdempotencyKey($cart, 'snapshot-hash-1'); + $keyC = $module->buildTwoOrderCreateIdempotencyKey($cart, 'snapshot-hash-2'); + + TinyAssert::same($keyA, $keyB); + TinyAssert::notSame($keyA, $keyC); + } + + private static function testHasTwoOrderRebindingConflictDetectsMismatchedTwoOrderIds(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->hasTwoOrderRebindingConflict([ + 'two_order_id' => 'two-existing-1', + ], 'two-incoming-2')); + + TinyAssert::false($module->hasTwoOrderRebindingConflict([ + 'two_order_id' => 'two-existing-1', + ], 'two-existing-1')); + + TinyAssert::false($module->hasTwoOrderRebindingConflict([ + 'two_order_id' => '', + ], 'two-incoming-2')); + } + + private static function testIsTwoAttemptCallbackAuthorizedWithMatchingKey(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 77, + 'customer_secure_key' => 'secure-key-77', + ]; + + TinyAssert::true($module->isTwoAttemptCallbackAuthorized($attempt, 'secure-key-77')); + } + + private static function testIsTwoAttemptCallbackAuthorizedFallsBackToContextCustomerKeyWhenRequestKeyMissing(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 99, + 'customer_secure_key' => 'secure-key-99', + ]; + + TinyAssert::true($module->isTwoAttemptCallbackAuthorized($attempt, '', 99, 'secure-key-99')); + } + + private static function testIsTwoAttemptCallbackAuthorizedRejectsMismatchedKeys(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $attempt = [ + 'id_customer' => 42, + 'customer_secure_key' => 'secure-key-42', + ]; + + TinyAssert::false($module->isTwoAttemptCallbackAuthorized($attempt, 'invalid-key', 42, 'secure-key-42')); + TinyAssert::false($module->isTwoAttemptCallbackAuthorized($attempt, '', 41, 'secure-key-42')); + } + + private static function testGetTwoBuyerPortalUrlUsesEnvironmentSpecificBuyerDomains(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'production'); + TinyAssert::same('https://buyer.two.inc/login', $module->getTwoBuyerPortalUrl()); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'development'); + TinyAssert::same('https://buyer.sandbox.two.inc/login', $module->getTwoBuyerPortalUrl()); + + Configuration::updateValue('PS_TWO_ENVIRONMENT', 'staging'); + TinyAssert::same('https://buyer.sandbox.two.inc/login', $module->getTwoBuyerPortalUrl()); + } + + private static function testResolveTwoAttemptOrderIdForCancellationPrefersAttemptOrderId(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public function getTwoOrderIdByCart($id_cart) + { + return 777; + } + }; + + $attempt = [ + 'id_order' => 321, + 'id_cart' => 123, + ]; + + TinyAssert::same(321, $module->resolveTwoAttemptOrderIdForCancellation($attempt)); + } + + private static function testResolveTwoAttemptOrderIdForCancellationFallsBackToCartOrderId(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public function getTwoOrderIdByCart($id_cart) + { + return ((int)$id_cart === 123) ? 654 : 0; + } + }; + + $attempt = [ + 'id_order' => 0, + 'id_cart' => 123, + ]; + + TinyAssert::same(654, $module->resolveTwoAttemptOrderIdForCancellation($attempt)); + } + + private static function testShouldBlockTwoAttemptConfirmationByStatusOnlyForCancelled(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->shouldBlockTwoAttemptConfirmationByStatus('CANCELLED')); + TinyAssert::true($module->shouldBlockTwoAttemptConfirmationByStatus('cancelled')); + TinyAssert::false($module->shouldBlockTwoAttemptConfirmationByStatus('CREATED')); + TinyAssert::false($module->shouldBlockTwoAttemptConfirmationByStatus('CONFIRMED')); + } + + private static function testIsTwoAttemptStatusTerminalMatchesCancelledGuard(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->isTwoAttemptStatusTerminal('CANCELLED')); + TinyAssert::false($module->isTwoAttemptStatusTerminal('CONFIRMED')); + } + + private static function testGetTwoCancelledOrderStatusIdUsesConfiguredFallbackChain(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + Configuration::updateValue('PS_TWO_OS_CANCELLED_MAP', 902); + Configuration::updateValue('PS_OS_CANCELED', 903); + TinyAssert::same(901, $module->getTwoCancelledOrderStatusId()); + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 0); + TinyAssert::same(902, $module->getTwoCancelledOrderStatusId()); + + Configuration::updateValue('PS_TWO_OS_CANCELLED_MAP', 0); + TinyAssert::same(903, $module->getTwoCancelledOrderStatusId()); + } + + private static function testSyncLocalOrderStatusFromTwoStateCancelsOnlyWhenProviderCancelled(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public $calls = []; + + public function changeOrderStatus($id_order, $id_order_status) + { + $this->calls[] = [(int)$id_order, (int)$id_order_status]; + return true; + } + }; + + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + TinyAssert::true($module->syncLocalOrderStatusFromTwoState(55, 'CANCELLED')); + TinyAssert::count(1, $module->calls); + TinyAssert::same([55, 901], $module->calls[0]); + + TinyAssert::false($module->syncLocalOrderStatusFromTwoState(56, 'CONFIRMED')); + TinyAssert::count(1, $module->calls); + } + + private static function testIsTwoOrderCancelledResponseRequires2xxAndCancelledState(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->isTwoOrderCancelledResponse([ + 'http_status' => 200, + 'state' => 'CANCELLED', + ])); + + TinyAssert::false($module->isTwoOrderCancelledResponse([ + 'http_status' => 200, + 'state' => 'CONFIRMED', + ])); + + TinyAssert::false($module->isTwoOrderCancelledResponse([ + 'http_status' => 500, + 'state' => 'CANCELLED', + ])); + + TinyAssert::false($module->isTwoOrderCancelledResponse([], 200)); + } + + private static function testShouldBlockTwoFulfillmentByTwoStateOnlyForCancelled(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->shouldBlockTwoFulfillmentByTwoState('CANCELLED')); + TinyAssert::true($module->shouldBlockTwoFulfillmentByTwoState('cancelled')); + TinyAssert::false($module->shouldBlockTwoFulfillmentByTwoState('CONFIRMED')); + TinyAssert::false($module->shouldBlockTwoFulfillmentByTwoState('')); + } + + private static function testShouldBlockTwoStatusTransitionByCancelledStateCoversVerifiedAndFulfillment(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT', 901); + Configuration::updateValue('PS_TWO_OS_FULFILLED_MAP', json_encode([4])); + Configuration::updateValue('PS_OS_SHIPPING', 4); + + TinyAssert::true($module->shouldBlockTwoStatusTransitionByCancelledState(901)); + TinyAssert::true($module->shouldBlockTwoStatusTransitionByCancelledState(4)); + TinyAssert::false($module->shouldBlockTwoStatusTransitionByCancelledState(99)); + } + + private static function testIsTwoOrderFulfillableStateRequiresConfirmed(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->isTwoOrderFulfillableState('CONFIRMED')); + TinyAssert::true($module->isTwoOrderFulfillableState('confirmed')); + TinyAssert::false($module->isTwoOrderFulfillableState('CANCELLED')); + TinyAssert::false($module->isTwoOrderFulfillableState('VERIFIED')); + } + + private static function testAddTwoBackOfficeWarningAppendsUniqueWarning(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + $module->context->controller = (object) ['warnings' => []]; + + TinyAssert::true($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + TinyAssert::count(1, $module->context->controller->warnings); + + TinyAssert::true($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + TinyAssert::count(1, $module->context->controller->warnings); + } + + private static function testAddTwoBackOfficeWarningReturnsFalseWhenNoController(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + $module->context->controller = null; + + TinyAssert::false($module->addTwoBackOfficeWarning('Fulfillment blocked warning')); + } + + private static function testApplyTwoCancelledOrderStateProfileToStatusObjectUsesConfiguredCancelledState(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + + $status = (object) [ + 'id' => 4, + 'shipped' => 1, + 'logable' => 1, + ]; + + TinyAssert::true($module->applyTwoCancelledOrderStateProfileToStatusObject($status, 1)); + TinyAssert::same(901, (int)$status->id); + TinyAssert::same(0, (int)$status->shipped); + TinyAssert::same(0, (int)$status->logable); + } + + private static function testForceTwoCancelledOrderHistoryStateBeforeInsertRewritesPendingStatus(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + Configuration::updateValue('PS_TWO_OS_CANCELLED', 901); + + $history = (object) [ + 'id_order_state' => 4, + 'logable' => 1, + ]; + + $order = new class { + public $loaded = true; + public $id_lang = 1; + public $current_state = 4; + public $valid = true; + public $updated = false; + + public function update() + { + $this->updated = true; + return true; + } + }; + + TinyAssert::true($module->forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, 'two-order-1', 'provider', 'CANCELLED')); + TinyAssert::same(901, (int)$history->id_order_state); + TinyAssert::same(901, (int)$order->current_state); + TinyAssert::false((bool)$order->valid); + TinyAssert::true($order->updated); + } + + private static function testGetTwoCheckoutCompanyDataUsesAddressVatNumberForAnyCountry(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[801] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $address = new Address(801); + $data = $module->getTwoCheckoutCompanyData($address); + + TinyAssert::same('Acme UK Ltd', $data['company_name']); + TinyAssert::same('123456789', $data['organization_number']); + TinyAssert::same('GB', $data['country_iso']); + } + + private static function testGetTwoCheckoutCompanyDataPrefersCurrentAddressOrgNumberOverSessionCompany(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + // Stale session from previously selected UK address/company + $module->context->cookie->two_company_name = 'CHEESE AND BEES LTD'; + $module->context->cookie->two_company_id = 'SC806781'; + $module->context->cookie->two_company_country = 'GB'; + $module->context->cookie->two_company_address_id = '28'; + + // Current selected address is Spanish and has org number in VAT field + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[29] = [ + 'id_country' => 34, + 'company' => 'Queso y Abejas S.L.', + 'vat_number' => 'ESB12345678', + 'loaded' => true, + ]; + + $address = new Address(29); + $data = $module->getTwoCheckoutCompanyData($address); + + TinyAssert::same('Queso y Abejas S.L.', $data['company_name']); + TinyAssert::same('B12345678', $data['organization_number']); + TinyAssert::same('ES', $data['country_iso']); + } + + private static function testGetTwoCheckoutCompanyDataUsesValidatedCookieFallback(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $module->context->cookie->two_company_name = 'Acme ES S.L.'; + $module->context->cookie->two_company_id = 'B12345678'; + $module->context->cookie->two_company_country = 'ES'; + + StubStore::$addresses[802] = [ + 'id_country' => 34, + 'company' => '', + 'loaded' => true, + ]; + + $address = new Address(802); + $data = $module->getTwoCheckoutCompanyData($address); + + TinyAssert::same('Acme ES S.L.', $data['company_name']); + TinyAssert::same('B12345678', $data['organization_number']); + TinyAssert::same('ES', $data['country_iso']); + } + + private static function testGetTwoCheckoutCompanyDataClearsStaleCookieOnCountryMismatch(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $module->context->cookie->two_company_name = 'Acme Norge'; + $module->context->cookie->two_company_id = 'NO123'; + $module->context->cookie->two_company_country = 'NO'; + + StubStore::$addresses[803] = [ + 'id_country' => 34, + 'company' => '', + 'loaded' => true, + ]; + + $address = new Address(803); + $data = $module->getTwoCheckoutCompanyData($address); + + TinyAssert::same('', $data['company_name']); + TinyAssert::same('', $data['organization_number']); + TinyAssert::same('ES', $data['country_iso']); + TinyAssert::false(isset($module->context->cookie->two_company_name)); + TinyAssert::false(isset($module->context->cookie->two_company_id)); + TinyAssert::false(isset($module->context->cookie->two_company_country)); + } + + private static function testGetTwoCheckoutCompanyDataIgnoresStaleCookieWhenAddressCompanyChangesSameCountry(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $module->context->cookie->two_company_name = 'Acme ES S.L.'; + $module->context->cookie->two_company_id = 'B12345678'; + $module->context->cookie->two_company_country = 'ES'; + $module->context->cookie->two_company_address_id = '999'; + + StubStore::$addresses[804] = [ + 'id_country' => 34, + 'company' => 'Beta Industrial S.L.', + 'loaded' => true, + ]; + + $address = new Address(804); + $data = $module->getTwoCheckoutCompanyData($address); + + TinyAssert::same('Beta Industrial S.L.', $data['company_name']); + TinyAssert::same('', $data['organization_number']); + TinyAssert::same('ES', $data['country_iso']); + } + + private static function testSaveGeneralFormDoesNotChangeSslVerificationFlag(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public function saveGeneralForTest(): void + { + $this->saveTwoGeneralFormValues(); + } + }; + + Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', 1); + Tools::setTestValue('PS_TWO_DISABLE_SSL_VERIFY', 0); + Tools::setTestValue('PS_TWO_ENVIRONMENT', 'development'); + Tools::setTestValue('PS_TWO_TITLE_1', 'Two title'); + Tools::setTestValue('PS_TWO_SUB_TITLE_1', 'Two subtitle'); + Tools::setTestValue('PS_TWO_MERCHANT_SHORT_NAME', 'merchant'); + Tools::setTestValue('PS_TWO_MERCHANT_API_KEY', 'api-key'); + + $module->saveGeneralForTest(); + + TinyAssert::same(1, (int) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY')); + } + + private static function testSaveOtherFormUpdatesSslVerificationFlag(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public function saveOtherForTest(): void + { + $this->saveTwoOtherFormValues(); + } + }; + + Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', 0); + Configuration::updateValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 1); + Tools::setTestValue('PS_TWO_DISABLE_SSL_VERIFY', 1); + Tools::setTestValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 0); + + $module->saveOtherForTest(); + + TinyAssert::same(1, (int) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY')); + TinyAssert::same(0, (int) Configuration::get('PS_TWO_ENABLE_TAX_SUBTOTALS')); + } + + private static function testOtherSettingsFormDoesNotExposeOrderIntentToggle(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public function getOtherFormForTest(): array + { + return $this->getTwoOtherForm(); + } + }; + + $form = $module->getOtherFormForTest(); + $inputNames = array_map(function ($field) { + return isset($field['name']) ? (string) $field['name'] : ''; + }, $form['form']['input']); + + TinyAssert::false(in_array('PS_TWO_ENABLE_ORDER_INTENT', $inputNames, true)); + TinyAssert::true(in_array('PS_TWO_ENABLE_TAX_SUBTOTALS', $inputNames, true)); + } + + private static function testHookActionAdminControllerSetMediaRegistersCssOnModuleConfigPage(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $controller = new class { + public $controller_name = 'AdminModules'; + public $php_self = 'module'; + public $styles = []; + + public function registerStylesheet($id, $path, $options = []) + { + $this->styles[] = [ + 'id' => $id, + 'path' => $path, + 'options' => $options, + ]; + } + }; + + $module->context->controller = $controller; + Tools::setTestValue('configure', 'twopayment'); + Tools::setTestValue('controller', 'AdminModules'); + + $module->hookActionAdminControllerSetMedia(); + + TinyAssert::same(1, count($controller->styles)); + TinyAssert::same('module-twopayment-admin-css', $controller->styles[0]['id']); + } + + private static function testHookActionAdminControllerSetMediaSkipsUnrelatedAdminPage(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $controller = new class { + public $controller_name = 'AdminProducts'; + public $php_self = 'products'; + public $styles = []; + + public function registerStylesheet($id, $path, $options = []) + { + $this->styles[] = [ + 'id' => $id, + 'path' => $path, + 'options' => $options, + ]; + } + }; + + $module->context->controller = $controller; + Tools::setTestValue('configure', 'othermodule'); + Tools::setTestValue('controller', 'AdminProducts'); + + $module->hookActionAdminControllerSetMedia(); + + TinyAssert::same(0, count($controller->styles)); + } + + private static function testHookPaymentOptionsBlocksWhenAccountTypeMissingInStrictMode(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + $module->active = true; + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 1); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[901] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $cart = new Cart(501); + $cart->id_address_invoice = 901; + $cart->id_currency = 978; + $module->context->cart = $cart; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 978]]; + + $options = $module->hookPaymentOptions([]); + + TinyAssert::same(0, count($options)); + } + + private static function testHookPaymentOptionsBlocksNonBusinessWhenAccountTypePresent(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + $module->active = true; + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 1); + StubStore::$countries[34] = 'ES'; + StubStore::$addresses[902] = [ + 'id_country' => 34, + 'company' => 'Acme ES S.L.', + 'dni' => 'B12345678', + 'account_type' => 'private', + 'loaded' => true, + ]; + + $cart = new Cart(502); + $cart->id_address_invoice = 902; + $cart->id_currency = 978; + $module->context->cart = $cart; + StubStore::$currencies[978] = ['iso_code' => 'EUR', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 978]]; + + $options = $module->hookPaymentOptions([]); + + TinyAssert::same(0, count($options)); + } + + private static function testHookPaymentOptionsAllowsTwoCoveredCurrencies(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + $module->active = true; + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 0); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[904] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $covered = [ + 578 => 'NOK', + 826 => 'GBP', + 752 => 'SEK', + 840 => 'USD', + 208 => 'DKK', + 978 => 'EUR', + ]; + + foreach ($covered as $idCurrency => $iso) { + StubStore::$currencies[$idCurrency] = ['iso_code' => $iso, 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => $idCurrency]]; + + $cart = new Cart(504 + $idCurrency); + $cart->id_address_invoice = 904; + $cart->id_currency = $idCurrency; + $module->context->cart = $cart; + + $options = $module->hookPaymentOptions([]); + TinyAssert::same(1, count($options), 'Expected covered currency to be allowed: ' . $iso); + } + } + + private static function testHookPaymentOptionsBlocksUnsupportedCurrency(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + protected function getTwoPaymentOption() + { + return (object) ['method' => 'two']; + } + }; + + $module->active = true; + Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 0); + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[903] = [ + 'id_country' => 826, + 'company' => 'Acme UK Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + StubStore::$currencies[392] = ['iso_code' => 'JPY', 'loaded' => true]; + StubStore::$moduleCurrencies['twopayment'] = [['id_currency' => 392]]; + + $cart = new Cart(503); + $cart->id_address_invoice = 903; + $cart->id_currency = 392; + $module->context->cart = $cart; + + $options = $module->hookPaymentOptions([]); + + TinyAssert::same(0, count($options)); + } + + private static function testMergeTwoPaymentTermFallbackUsesFallbackWhenMissing(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $base = [ + 'id_order' => 11, + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + ]; + $fallback = [ + 'two_day_on_invoice' => '45', + 'two_payment_term_type' => 'EOM', + ]; + + $merged = $module->mergeTwoPaymentTermFallback($base, $fallback); + + TinyAssert::same('45', (string) $merged['two_day_on_invoice']); + TinyAssert::same('EOM', (string) $merged['two_payment_term_type']); + } + + private static function testMergeTwoPaymentTermFallbackKeepsExistingValues(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $base = [ + 'id_order' => 12, + 'two_day_on_invoice' => '30', + 'two_payment_term_type' => 'STANDARD', + ]; + $fallback = [ + 'two_day_on_invoice' => '60', + 'two_payment_term_type' => 'EOM', + ]; + + $merged = $module->mergeTwoPaymentTermFallback($base, $fallback); + + TinyAssert::same('30', (string) $merged['two_day_on_invoice']); + TinyAssert::same('STANDARD', (string) $merged['two_payment_term_type']); + } + + private static function testShouldExposeTwoInvoiceActionsRequiresFulfilledState(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + TinyAssert::true($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'FULFILLED'])); + TinyAssert::false($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'VERIFIED'])); + TinyAssert::false($module->shouldExposeTwoInvoiceActions(['two_order_state' => 'CONFIRMED'])); + TinyAssert::false($module->shouldExposeTwoInvoiceActions(['two_order_state' => ''])); + } + + private static function testResolveTwoPaymentTermsFromOrderResponseUsesEndOfMonthAsEom(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $response = [ + 'terms' => [ + 'duration_days' => 60, + 'duration_days_calculated_from' => 'END_OF_MONTH', + ], + ]; + + $resolved = $module->resolveTwoPaymentTermsFromOrderResponse($response, '30', 'STANDARD'); + + TinyAssert::same('60', (string)$resolved['two_day_on_invoice']); + TinyAssert::same('EOM', (string)$resolved['two_payment_term_type']); + } + + private static function testResolveTwoPaymentTermsFromOrderResponseFallsBackToStandardForUnsupportedScheme(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $response = [ + 'terms' => [ + 'duration_days' => 45, + 'duration_days_calculated_from' => 'END_OF_WEEK', + ], + ]; + + $resolved = $module->resolveTwoPaymentTermsFromOrderResponse($response, '30', 'EOM'); + + TinyAssert::same('45', (string)$resolved['two_day_on_invoice']); + TinyAssert::same('STANDARD', (string)$resolved['two_payment_term_type']); + } + + private static function testSyncTwoAdminOrderPaymentDataFromProviderPullsLatestTermsFromTwo(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-123') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'id' => 'two-123', + 'merchant_reference' => 'MR-123', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'invoice_url' => 'https://two.test/invoice/123', + 'invoice_details' => ['id' => 'inv-123'], + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => 'END_OF_MONTH', + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 55, + 'two_order_id' => 'two-123', + 'two_order_reference' => '', + 'two_order_state' => 'VERIFIED', + 'two_order_status' => 'APPROVED', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(55, $base); + + TinyAssert::same('60', (string)$synced['two_day_on_invoice']); + TinyAssert::same('EOM', (string)$synced['two_payment_term_type']); + TinyAssert::same('CONFIRMED', (string)$synced['two_order_state']); + TinyAssert::same('MR-123', (string)$synced['two_order_reference']); + TinyAssert::same(55, (int)$module->lastSavedOrderId); + TinyAssert::same('60', (string)$module->lastSavedPaymentData['two_day_on_invoice']); + } + + private static function testSyncTwoAdminOrderPaymentDataFromProviderSupportsNestedDataEnvelope(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-456') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'data' => [ + 'id' => 'two-456', + 'merchant_reference' => 'MR-456', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'invoice_url' => 'https://two.test/invoice/456', + 'invoice_details' => ['id' => 'inv-456'], + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => null, + ], + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 56, + 'two_order_id' => 'two-456', + 'two_order_reference' => '', + 'two_order_state' => '', + 'two_order_status' => '', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(56, $base); + + TinyAssert::same('60', (string)$synced['two_day_on_invoice']); + TinyAssert::same('STANDARD', (string)$synced['two_payment_term_type']); + TinyAssert::same('MR-456', (string)$synced['two_order_reference']); + TinyAssert::same(56, (int)$module->lastSavedOrderId); + } + + private static function testSyncTwoAdminOrderPaymentDataFromProviderRecoversMissingTwoOrderIdFromAttempt(): void + { + self::reset(); + $module = new class extends TwopaymentTestHarness { + public $lastSavedOrderId = null; + public $lastSavedPaymentData = null; + + protected function getLatestTwoCheckoutAttemptByOrder($id_order) + { + return array( + 'two_order_id' => 'two-789', + ); + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method === 'GET' && $endpoint === '/v1/order/two-789') { + return [ + 'http_status' => Twopayment::HTTP_STATUS_OK, + 'id' => 'two-789', + 'merchant_reference' => 'MR-789', + 'state' => 'CONFIRMED', + 'status' => 'PENDING', + 'terms' => [ + 'type' => 'NET_TERMS', + 'duration_days' => 60, + 'duration_days_calculated_from' => null, + ], + ]; + } + + return ['http_status' => 500]; + } + + public function setTwoOrderPaymentData($id_order, $payment_data) + { + $this->lastSavedOrderId = (int)$id_order; + $this->lastSavedPaymentData = $payment_data; + } + + public function syncAdminDataForTest($id_order, $twopaymentdata) + { + return $this->syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata); + } + }; + + $base = [ + 'id_order' => 57, + 'two_order_id' => '', + 'two_order_reference' => '', + 'two_order_state' => '', + 'two_order_status' => '', + 'two_day_on_invoice' => '', + 'two_payment_term_type' => '', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ]; + + $synced = $module->syncAdminDataForTest(57, $base); + + TinyAssert::same('two-789', (string)$synced['two_order_id']); + TinyAssert::same('60', (string)$synced['two_day_on_invoice']); + TinyAssert::same('STANDARD', (string)$synced['two_payment_term_type']); + TinyAssert::same(57, (int)$module->lastSavedOrderId); + } + + private static function testGetLatestTwoCheckoutAttemptByOrderSelectsTwoOrderIdForFallbackRecovery(): void + { + self::reset(); + StubStore::$dbExecuteSResponses[] = array( + array( + 'two_order_id' => 'two-fallback-1', + 'status' => 'CANCELLED', + 'two_day_on_invoice' => '60', + 'two_payment_term_type' => 'STANDARD', + 'two_order_state' => 'CONFIRMED', + 'two_order_status' => 'PENDING', + 'two_invoice_url' => '', + 'two_invoice_id' => '', + ), + ); + + $module = new class extends TwopaymentTestHarness { + public function getLatestAttemptForTest($id_order) + { + return $this->getLatestTwoCheckoutAttemptByOrder($id_order); + } + }; + + $latest = $module->getLatestAttemptForTest(57); + + TinyAssert::true(is_array($latest)); + TinyAssert::same('two-fallback-1', (string)$latest['two_order_id']); + TinyAssert::true(!empty(StubStore::$dbLastExecuteS)); + TinyAssert::true(strpos(StubStore::$dbLastExecuteS[0], '`two_order_id`') !== false); + TinyAssert::true(strpos(StubStore::$dbLastExecuteS[0], '`status`') !== false); + TinyAssert::true(strpos(StubStore::$dbLastExecuteS[0], '`id_order` = 57') !== false); + } + + private static function testGetTwoValidatedSessionCompanyDataRejectsCountryMismatch(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme Ltd'; + $module->context->cookie->two_company_id = 'NO123'; + $module->context->cookie->two_company_country = 'NO'; + + $data = $module->getTwoValidatedSessionCompanyData('ES'); + + TinyAssert::same('', $data['company_name']); + TinyAssert::same('', $data['organization_number']); + TinyAssert::false(isset($module->context->cookie->two_company_name)); + TinyAssert::false(isset($module->context->cookie->two_company_id)); + TinyAssert::false(isset($module->context->cookie->two_company_country)); + } + + private static function testGetTwoValidatedSessionCompanyDataRejectsLegacySessionWithoutCountryMarker(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + $module->context->cookie->two_company_name = 'Acme Ltd'; + $module->context->cookie->two_company_id = 'NO123'; + + $data = $module->getTwoValidatedSessionCompanyData('ES'); + + TinyAssert::same('', $data['company_name']); + TinyAssert::same('', $data['organization_number']); + TinyAssert::false(isset($module->context->cookie->two_company_name)); + TinyAssert::false(isset($module->context->cookie->two_company_id)); + } + + private static function testBuildTwoApiResponseLogSummaryRedactsNestedProviderPayload(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $summary = $module->buildTwoApiResponseLogSummary([ + 'http_status' => 400, + 'id' => 'two-order-1', + 'state' => 'CREATED', + 'status' => 'PENDING', + 'merchant_reference' => 'merchant-ref-1', + 'error' => 'validation_error', + 'data' => [ + 'invoice_url' => 'https://sensitive.example/invoice', + 'buyer' => ['email' => 'buyer@example.com'], + ], + ]); + + TinyAssert::same(400, $summary['http_status']); + TinyAssert::same('two-order-1', $summary['two_order_id']); + TinyAssert::same('CREATED', $summary['two_order_state']); + TinyAssert::same('PENDING', $summary['two_order_status']); + TinyAssert::same('merchant-ref-1', $summary['two_order_reference']); + TinyAssert::same('validation_error', $summary['error']); + TinyAssert::false(isset($summary['data'])); + TinyAssert::false(isset($summary['invoice_url'])); + } + + private static function testGetTwoErrorMessageReturnsHttpFallbackForNonJsonProviderErrors(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 502, + 'data' => null, + ]); + + TinyAssert::same('Two response code 502', $message); + } + + private static function testGetTwoErrorMessageReadsNestedDataMessage(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 400, + 'data' => [ + 'error_message' => 'Validation failed', + ], + ]); + + TinyAssert::same('Validation failed', $message); + } + + private static function testGetTwoErrorMessageIgnoresSuccessMessagePayload(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $message = $module->getTwoErrorMessage([ + 'http_status' => 200, + 'message' => 'Order confirmed', + ]); + + TinyAssert::same(null, $message); + } + + private static function testGetTwoProductItemsSkipsEmptyBarcodeEntries(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + $cart = new Cart(811); + $cart->id_lang = 1; + $cart->id_carrier = 999; + + StubStore::$cartProducts[811] = [[ + 'id_product' => 701, + 'link_rewrite' => 'office-chair', + 'name' => 'Office Chair', + 'description_short' => 'Ergonomic chair', + 'manufacturer_name' => 'Acme', + 'ean13' => '', + 'upc' => '', + 'total' => 100.00, + 'total_wt' => 121.00, + 'cart_quantity' => 1, + 'rate' => 21.0, + 'price' => 100.00, + 'reduction' => 0, + ]]; + + StubStore::$productCategories[701] = [['name' => 'Furniture']]; + StubStore::$images[701] = ['id_image' => 9901]; + + $items = $module->getTwoProductItems($cart); + + TinyAssert::count(1, $items); + TinyAssert::same([], $items[0]['details']['barcodes']); + } + + private static function testExtractOrgNumberFromAddressKeepsNonCountryPrefixVatNumber(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[812] = [ + 'id_country' => 826, + 'company' => 'Cheese Box Ltd', + 'vat_number' => 'SC806781', + 'loaded' => true, + ]; + + $address = new Address(812); + $orgNumber = $module->extractOrgNumberFromAddress($address, 'GB'); + + TinyAssert::same('SC806781', $orgNumber); + } + + private static function testExtractOrgNumberFromAddressStripsMatchingCountryPrefixVatNumber(): void + { + self::reset(); + $module = new TwopaymentTestHarness(); + + StubStore::$countries[826] = 'GB'; + StubStore::$addresses[813] = [ + 'id_country' => 826, + 'company' => 'Cheese Box Ltd', + 'vat_number' => 'GB123456789', + 'loaded' => true, + ]; + + $address = new Address(813); + $orgNumber = $module->extractOrgNumberFromAddress($address, 'GB'); + + TinyAssert::same('123456789', $orgNumber); + } +} + +require __DIR__ . '/CustomerAddressFormatterOverrideSpec.php'; + +$tests = [ + 'OrderBuilderSpec::runAll' => [OrderBuilderSpec::class, 'runAll'], + 'CustomerAddressFormatterOverrideSpec::runAll' => [CustomerAddressFormatterOverrideSpec::class, 'runAll'], +]; + +$failed = 0; +foreach ($tests as $name => $callable) { + try { + $callable(); + echo "PASS {$name}\n"; + } catch (Throwable $e) { + $failed++; + fwrite(STDERR, "FAIL {$name}: {$e->getMessage()}\n"); + } +} + +if ($failed > 0) { + exit(1); +} + +echo "All tests passed.\n"; diff --git a/translations/es.php b/translations/es.php index 9dad7ee..383f0cd 100644 --- a/translations/es.php +++ b/translations/es.php @@ -57,8 +57,20 @@ $_MODULE['<{twopayment}prestashop>twopayment_e175af11a33d489ee241fc5c4931d4d4'] = 'Si seleccionas SÍ, los clientes verán el campo de Proyecto en el proceso de compra.'; $_MODULE['<{twopayment}prestashop>twopayment_1287d93b62a3cf8417e0c75f8f71c66b'] = 'Cumplir pedidos automáticamente con Two'; $_MODULE['<{twopayment}prestashop>twopayment_8b667ac78d92f83cddeca84b8a22cce2'] = 'Cuando está habilitado, los pedidos se marcan automáticamente como cumplidos en Two cuando su estado cambia a uno de los estados de activación de cumplimiento que hayas configurado (consulta la asignación de estados de pedido). Esto activa los términos de pago del comprador y comienza el ciclo de pagos. Si está deshabilitado, debes cumplir los pedidos manualmente desde el Portal de Comerciantes de Two.'; -$_MODULE['<{twopayment}prestashop>twopayment_e674699beb159864f8c5ebd45305590c'] = 'Uso de facturas propias'; -$_MODULE['<{twopayment}prestashop>twopayment_6319765e9c08e8feef18ca0c5aac018e'] = 'Solo debe utilizarse si gestionas tu propia distribución de facturas y notas de crédito, y debe comunicarse a Two como parte de tu implementación para garantizar que la generación de facturas de Two esté deshabilitada. Si este ajuste está habilitado, las facturas de PrestaShop se cargarán en Two cuando los pedidos se marquen como cumplidos.'; +$_MODULE['<{twopayment}prestashop>twopayment_fe23d34260c5dd03950bf17958f4d6ae'] = 'Subir facturas propias a Two'; +$_MODULE['<{twopayment}prestashop>twopayment_87cd64c3a71c8823d9093ce0484f99c9'] = 'Activa esto SOLO si usas tus propias facturas en lugar de las facturas generadas por Two. Debe coordinarse con Two antes de activarlo.'; +$_MODULE['<{twopayment}prestashop>twopayment_6646bb7bbf12bbc7c53b4deb1f197e98'] = 'Cuando está habilitado:'; +$_MODULE['<{twopayment}prestashop>twopayment_ffeac579f7d5ed5464965b923ab3ccfa'] = 'Tus facturas de PrestaShop se subirán a Two cuando se cumplan los pedidos'; +$_MODULE['<{twopayment}prestashop>twopayment_9d76bd53552662fa2afa0a68063c5267'] = 'Two NO generará facturas: se usará tu factura en su lugar'; +$_MODULE['<{twopayment}prestashop>twopayment_a2dda3f8a0bba8c4a7dbd64f44134d9d'] = 'OBLIGATORIO: debes personalizar tu plantilla de factura'; +$_MODULE['<{twopayment}prestashop>twopayment_458eb5b63b66e59113e87d9356115a61'] = 'Edita tu plantilla de factura para incluir los datos de pago de Two SOLO PARA PEDIDOS DE TWO.'; +$_MODULE['<{twopayment}prestashop>twopayment_b35130db4a76ba55ef250fdcac228e9c'] = 'Ubicación de la plantilla:'; +$_MODULE['<{twopayment}prestashop>twopayment_e81c4e4f2b7b93b481e13a8553c2ae1b'] = 'o'; +$_MODULE['<{twopayment}prestashop>twopayment_85827e9f33a1e80cb81988a448cf9362'] = 'Añade este código a tu plantilla de factura:'; +$_MODULE['<{twopayment}prestashop>twopayment_fca5999dcb67db13c8f61dcb5416812c'] = 'Two te proporcionará los datos bancarios y el formato de referencia de pago específicos que debes incluir.'; +$_MODULE['<{twopayment}prestashop>twopayment_48c9eb2ed2048d4f5e0b67fba92dc385'] = 'Importante: contacta con soporte de Two antes de activar esta función.'; +$_MODULE['<{twopayment}prestashop>twopayment_9af4c01d9a1ebe24c5ae45e7b07024c3'] = 'Enviar subtotales de impuestos en las cargas útiles de solicitud'; +$_MODULE['<{twopayment}prestashop>twopayment_d2a7c39c5ec025981855847fed006923'] = 'Si seleccionas SÍ, tax_subtotals se enviará en las cargas útiles de /v1/order y /v1/order_intent. Si seleccionas NO, tax_subtotals se omitirá en esas cargas útiles.'; $_MODULE['<{twopayment}prestashop>twopayment_a56829d4783f7416b716c4e547ceb828'] = 'Preaprobar al comprador durante el proceso de compra y desactivar Two si la solicitud es rechazada'; $_MODULE['<{twopayment}prestashop>twopayment_d68e0d956cafd369eecf08a556ba13b7'] = 'Si seleccionas SÍ, el comprador será preaprobado durante el proceso de compra y Two se desactivará si la solicitud es rechazada.'; $_MODULE['<{twopayment}prestashop>twopayment_21b310fbc2bfba901b28b7b9c6591b56'] = 'Desactivar la verificación SSL (Solo para redes corporativas)'; @@ -68,6 +80,69 @@ $_MODULE['<{twopayment}prestashop>twopayment_dc01133f318ab68ac223d3e362927266'] = 'Activar el modo de depuración'; $_MODULE['<{twopayment}prestashop>twopayment_438aabf30a911f97856887a18c2b52e7'] = 'Activar el registro detallado para resolución de problemas. Registra cálculos de impuestos y otros datos de diagnóstico. Solo actívalo cuando lo solicite el soporte de Two.'; $_MODULE['<{twopayment}prestashop>twopayment_4b055fb802f4de1edd89040fedc79dda'] = 'Las otras configuraciones se han actualizado.'; +$_MODULE['<{twopayment}prestashop>twopayment_8b71f88a3f44283f2f9d4905d1b097f1'] = 'Qué hace este plugin'; +$_MODULE['<{twopayment}prestashop>twopayment_49e302e8bd371d3216feb401bbb543d9'] = 'Two es una solución B2B de Compra ahora, paga después'; +$_MODULE['<{twopayment}prestashop>twopayment_07b18d92f97de5262310e8d5493d05a9'] = 'Este plugin permite a clientes empresariales pagar con factura con decisiones de crédito instantáneas.'; +$_MODULE['<{twopayment}prestashop>twopayment_99315aa41524d1a9a67fb031b473be73'] = 'Lo que el plugin SÍ puede hacer'; +$_MODULE['<{twopayment}prestashop>twopayment_28c190bc79712b036a783893d4e18059'] = 'Aceptar pagos B2B con factura y aprobación de crédito instantánea'; +$_MODULE['<{twopayment}prestashop>twopayment_2db402469dd03ef70d28a292f12eb8c7'] = 'Búsqueda y validación de empresas en el checkout (autocompletado)'; +$_MODULE['<{twopayment}prestashop>twopayment_bc5e69470fb58f31cb12c7e95bede662'] = 'Comprobación en tiempo real de elegibilidad del comprador (Order Intent) antes de la compra'; +$_MODULE['<{twopayment}prestashop>twopayment_5ea058e4746950c51516f030cae91320'] = 'Cumplimiento automático de pedidos cuando cambia el estado del pedido (configurable)'; +$_MODULE['<{twopayment}prestashop>twopayment_16ca4b14c683275e516bbb4def8a837f'] = 'Compatibilidad con plazos de pago estándar y de fin de mes (EOM)'; +$_MODULE['<{twopayment}prestashop>twopayment_bb1697a94fe9e88e0c4b2b0dd605eced'] = 'Plazos de pago configurables (7, 15, 20, 30, 45, 60, 90 días)'; +$_MODULE['<{twopayment}prestashop>twopayment_79581e5bb8de33fe6bcf4f233275c5a8'] = 'Gestionar reembolsos completos desde el administrador de PrestaShop'; +$_MODULE['<{twopayment}prestashop>twopayment_72dc26b23d6cc214df77dca5d7953fa5'] = 'Mostrar información del pedido de Two en la vista de pedidos del administrador'; +$_MODULE['<{twopayment}prestashop>twopayment_f71fecea4e95aedc66e77754d9cecda4'] = 'Compatibilidad con múltiples tipos impositivos y clientes exentos de impuestos'; +$_MODULE['<{twopayment}prestashop>twopayment_26aa493188d501eef826610ffb69c486'] = 'Gestionar correctamente las reglas de envío gratuito y los descuentos'; +$_MODULE['<{twopayment}prestashop>twopayment_624d960f1513f62685fb2017c0bbc926'] = 'Compatible con PrestaShop 1.7.6 hasta 9.x'; +$_MODULE['<{twopayment}prestashop>twopayment_547b272d62ed4ca5d2cd83e8b7b463da'] = 'Requisitos importantes'; +$_MODULE['<{twopayment}prestashop>twopayment_f12477236933ae49e5c643aecf75f74a'] = 'Los clientes deben tener un número de empresa/organización válido'; +$_MODULE['<{twopayment}prestashop>twopayment_b1021ae56c3544e96ea36a7d80e48145'] = 'Los clientes deben introducir el nombre de su empresa en la dirección de facturación'; +$_MODULE['<{twopayment}prestashop>twopayment_c9aef77d098e3d7bd2056eac6664ef96'] = 'Se requiere un número de teléfono válido para las verificaciones de crédito'; +$_MODULE['<{twopayment}prestashop>twopayment_6be9e6c7a97ef6916df30b37dcd5985a'] = 'Two debe aprobar al comprador antes de poder realizar el pedido'; +$_MODULE['<{twopayment}prestashop>twopayment_c66a99dc2e09bb78051ff4f11dd89f6c'] = 'Los productos deben tener correctamente configuradas las reglas de impuestos en PrestaShop'; +$_MODULE['<{twopayment}prestashop>twopayment_9bc4151431237fc71bc676000348f622'] = 'Lo que el plugin NO puede hacer'; +$_MODULE['<{twopayment}prestashop>twopayment_7d321c741afd81471bf466233f83f20b'] = 'Procesar pagos B2C (consumidor): Two es solo B2B'; +$_MODULE['<{twopayment}prestashop>twopayment_2ddfcde7e1c5a238826417454dc5033c'] = 'Garantizar la aprobación: Two realiza verificaciones de crédito en tiempo real'; +$_MODULE['<{twopayment}prestashop>twopayment_f68cf3bf9fe34d152a13fa6947c19d9b'] = 'Anular la decisión de crédito de Two o los límites del comprador.'; +$_MODULE['<{twopayment}prestashop>twopayment_7d83a7b416ad1d84977219bf3b5dd45d'] = 'Procesar reembolsos parciales: usa el Portal de Comerciantes de Two para reembolsos parciales'; +$_MODULE['<{twopayment}prestashop>twopayment_3d94e3cb25fc73d812643a4cf4d8354a'] = 'Cumplimiento parcial: los pedidos deben cumplirse por completo'; +$_MODULE['<{twopayment}prestashop>twopayment_c23d1b05a68bc7bf02d67b60fe043586'] = 'Corregir una configuración de impuestos incorrecta en tu tienda: los impuestos deben estar configurados correctamente en PrestaShop'; +$_MODULE['<{twopayment}prestashop>twopayment_a58a88e252eae380cbcff6421cd5d08c'] = 'Procesar pedidos sin un número de registro empresarial válido'; +$_MODULE['<{twopayment}prestashop>twopayment_8647c528dd8493997122a86c89fa8eea'] = 'Cambiar los plazos de pago después de realizar un pedido'; +$_MODULE['<{twopayment}prestashop>twopayment_98ec681d588dc0fae27ac945915ffe5e'] = 'Consejos de solución de problemas'; +$_MODULE['<{twopayment}prestashop>twopayment_6a980cd176fb2321da4ea77c9b8e1bd4'] = '¿El impuesto aparece al 0%?'; +$_MODULE['<{twopayment}prestashop>twopayment_969a811ead960f897fc85735d99d646a'] = 'Verifica que las reglas de impuestos estén configuradas para tu país en Internacional > Impuestos > Reglas de impuestos'; +$_MODULE['<{twopayment}prestashop>twopayment_1cda9ad7574b6a8fcfd2490b27ad18f2'] = '¿Comprador rechazado?'; +$_MODULE['<{twopayment}prestashop>twopayment_11c0c6552a36b6fff05e235aedff525e'] = 'La empresa puede haber alcanzado su límite de crédito o no haber superado la evaluación de crédito de Two'; +$_MODULE['<{twopayment}prestashop>twopayment_5ddf00b8e7663693432c08c35630f1b3'] = '¿Empresa no encontrada?'; +$_MODULE['<{twopayment}prestashop>twopayment_b7e84545a616108ac9b4496b15a60966'] = 'El cliente debe introducir el nombre oficial registrado de su empresa'; +$_MODULE['<{twopayment}prestashop>twopayment_59df4ccb8a04c9449672080b8f497ec1'] = '¿Teléfono no válido?'; +$_MODULE['<{twopayment}prestashop>twopayment_5c57988f52818e8b1a6683e5fc63cbeb'] = 'Asegúrate de que el número de teléfono incluya el código de país y tenga un formato válido'; +$_MODULE['<{twopayment}prestashop>twopayment_2f6653d72084d1585721cb272597728f'] = '¿Errores de discrepancia de importes?'; +$_MODULE['<{twopayment}prestashop>twopayment_87870bed762c812b3f2450172426fa04'] = 'Activa el modo de depuración en Otras configuraciones y contacta con soporte de Two con los registros'; +$_MODULE['<{twopayment}prestashop>twopayment_14221b2ce674afde7f85cfb60662a5b7'] = '¿Necesitas ayuda?'; +$_MODULE['<{twopayment}prestashop>twopayment_23b78e764a2e8c398e2cd94e9e33ecf5'] = 'Para soporte técnico o consultas sobre este plugin:'; +$_MODULE['<{twopayment}prestashop>twopayment_6a1e265f92087bb6dd18194833fe946b'] = 'Correo electrónico:'; +$_MODULE['<{twopayment}prestashop>twopayment_8faa7616b66ec7990abd90e6eb970b03'] = 'Documentación:'; +$_MODULE['<{twopayment}prestashop>twopayment_75245db579ccec068538202def09f353'] = 'Portal del comerciante:'; +$_MODULE['<{twopayment}prestashop>twopayment_066ff7212ff7b7d8f5e3e7effc1810bd'] = 'Abrir portal de Two'; +$_MODULE['<{twopayment}prestashop>twopayment_fb9c6bde479f74937d885d4984147a84'] = 'Versión del plugin:'; +$_MODULE['<{twopayment}prestashop>twopayment_31a1931b5703e90cf392686134f4aae1'] = 'PrestaShop:'; +$_MODULE['<{twopayment}prestashop>twopayment_656a6828d7ef1bb791e42087c4b5ee6e'] = 'Clave API'; +$_MODULE['<{twopayment}prestashop>twopayment_3f68e67dc6c397aaa9d1c24c356f754f'] = 'Verificado'; +$_MODULE['<{twopayment}prestashop>twopayment_ecddbd73a90aaf17dd71134521b09079'] = 'No verificado'; +$_MODULE['<{twopayment}prestashop>twopayment_a4f2f007d16e05710980a4141c331168'] = 'Verificación SSL'; +$_MODULE['<{twopayment}prestashop>twopayment_b9f5c797ebbf55adccdd8539a65a0241'] = 'Desactivado'; +$_MODULE['<{twopayment}prestashop>twopayment_00d23a76e43b46dae9ec7aa9dcbebb32'] = 'Activado'; +$_MODULE['<{twopayment}prestashop>twopayment_0222811678aca4f949604dc6ffb02e9d'] = 'Prevalidación con Order Intent'; +$_MODULE['<{twopayment}prestashop>twopayment_bff337e30b9486d358e8401bde7d7abd'] = 'Modo de tipo de cuenta'; +$_MODULE['<{twopayment}prestashop>twopayment_d510626d757fd594c73232574fd56d64'] = 'Plazos de pago'; +$_MODULE['<{twopayment}prestashop>twopayment_35e20456c7fa8d5e53e003d7f6675d84'] = 'Estado actual de la configuración'; +$_MODULE['<{twopayment}prestashop>twopayment_f65e05deb9e54ed090b8450b4a4fbeb4'] = 'Aviso de seguridad:'; +$_MODULE['<{twopayment}prestashop>twopayment_bbe9f9e95ce9e3ed2a173b373b381286'] = 'La verificación SSL está desactivada en producción. Vuelve a activarla salvo que tu red requiera un proxy corporativo de confianza.'; +$_MODULE['<{twopayment}prestashop>twopayment_4296fd320beaf61dcf979ff634cf2f2d'] = 'Acción requerida:'; +$_MODULE['<{twopayment}prestashop>twopayment_64b235cc9d452ea7fb8008c6f9aefd91'] = 'La clave API no está verificada. Las solicitudes de checkout pueden fallar hasta que guardes Configuración general con una clave válida.'; $_MODULE['<{twopayment}prestashop>twopayment_75b05041144100917a4a0b80fdf676f5'] = 'Mapeo de estados de pedido de Two'; $_MODULE['<{twopayment}prestashop>twopayment_c72d2eb12f16e79b940e80b200786d01'] = 'Asocia los estados de pago de Two con los estados de pedido de PrestaShop para la integración del flujo de trabajo. Two crea automáticamente sus propios estados de pedido, pero puedes mapearlos con los existentes en PrestaShop si lo deseas.'; $_MODULE['<{twopayment}prestashop>twopayment_d5a4650cc30bac28c85f6c41646a39a2'] = 'Mapeos predeterminados:'; @@ -101,20 +176,42 @@ $_MODULE['<{twopayment}prestashop>twopayment_d2f68faa84d4bab378419dae024f85f9'] = '¡Pago aprobado! Elige tus plazos de pago a continuación.'; $_MODULE['<{twopayment}prestashop>twopayment_c24b167a42bc9624b05bb755eee9a550'] = 'El pago con Two no está disponible para este pedido.'; $_MODULE['<{twopayment}prestashop>twopayment_b1b74b4745652efd2f6578b1f35d23dd'] = 'Hubo un problema al procesar tu solicitud de pago con Two. Inténtalo de nuevo o elige otro método de pago.'; +$_MODULE['<{twopayment}prestashop>twopayment_70a6512b8cd4822b0d2e390dfe6c0177'] = 'Falló la comprobación de Order Intent'; +$_MODULE['<{twopayment}prestashop>twopayment_e8b268cfe5d84bee35ec6bf70a1fa9c7'] = 'Respuesta no válida del servidor'; +$_MODULE['<{twopayment}prestashop>twopayment_c1cf258d03ea6e7a7d9a004d3a017610'] = 'Elige la opción de Compra ahora, paga después que mejor se adapte a ti'; +$_MODULE['<{twopayment}prestashop>twopayment_a002c8066738bc8f9d9394abdcef7ea8'] = 'Tu período de pago comienza cuando se cumple tu pedido'; $_MODULE['<{twopayment}prestashop>twopayment_50031b9bc2eb203ae9af8fd430c5391d'] = 'Tu factura con Two probablemente será aceptada para %s'; $_MODULE['<{twopayment}prestashop>twopayment_9963aaf1a29f894206b4e6fd7febae61'] = 'Tu factura con Two no puede ser aprobada en este momento para %s'; +$_MODULE['<{twopayment}prestashop>twopayment_4f1792fb67a1e5d3a2ece5959d5be8f6'] = 'Tu factura con Two probablemente será aceptada'; +$_MODULE['<{twopayment}prestashop>twopayment_1e7adc305cad14009b99d18585a7f824'] = 'Tu factura con Two no puede aprobarse en este momento'; $_MODULE['<{twopayment}prestashop>twopayment_a7ae6fbd75c4968e5b9c92b908fe824e'] = 'El número de teléfono indicado en la dirección de facturación no es válido. Por favor, vuelve atrás y asegúrate de haber introducido un número de teléfono válido para tu país.'; $_MODULE['<{twopayment}prestashop>twopayment_93ef8e5b106e50d55fd88d4a966668c8'] = 'Para pagar con Two, vuelve a la dirección de facturación e introduce el nombre de tu empresa en el campo'; +$_MODULE['<{twopayment}prestashop>twopayment_0eb2ab65c0156ef3da0c1d96523c55a7'] = 'El nombre de la empresa es obligatorio para cuentas empresariales.'; $_MODULE['<{twopayment}prestashop>twopayment_86e2fb385e5a11785d5bc709a04e0e1a'] = 'Busca y selecciona una empresa válida para continuar con el pago con Two.'; $_MODULE['<{twopayment}prestashop>twopayment_0283fa93feb690f8b3537e18ed6bb4ab'] = 'Para pagar con Two, vuelve a la dirección de facturación y busca el nombre de tu empresa. Selecciona tu empresa en los resultados para verificar tu negocio.'; $_MODULE['<{twopayment}prestashop>twopayment_5a431dd829057ede6d4675460f53e310'] = 'La información de la empresa proporcionada no es válida. Busca y selecciona una empresa válida.'; $_MODULE['<{twopayment}prestashop>twopayment_be89e29be0122074a933bea5d3813084'] = 'No pudimos encontrar tu empresa. Prueba con otro nombre de empresa o contacta con soporte.'; $_MODULE['<{twopayment}prestashop>twopayment_ccf1aa7d0f1291cb8179d4434f85abb4'] = 'El pago con Two no está disponible para este pedido. Por favor, selecciona otro método de pago.'; $_MODULE['<{twopayment}prestashop>twopayment_70121086cdb2e52ce9ac069b1781dc76'] = 'Se produjo un problema temporal al verificar tu pago. Inténtalo de nuevo o elige otro método de pago.'; +$_MODULE['<{twopayment}prestashop>twopayment_36db71f088bd580af6416fa507d77a58'] = 'Tu pedido no pudo ser aprobado por el pago con Two. Por favor, elige otro método de pago o contacta con el soporte.'; +$_MODULE['<{twopayment}prestashop>twopayment_990f982c41ce66c911e505fb58fa8e46'] = 'No se puede procesar tu pedido con el pago de Two.'; +$_MODULE['<{twopayment}prestashop>twopayment_2ead200be9922b9284cb1324bea07c33'] = 'No se puede procesar tu pedido con el pago de Two. Revisa tu carrito e inténtalo de nuevo.'; +$_MODULE['<{twopayment}prestashop>twopayment_0e59e4dd7c8466ff99df71f1e49cbfa1'] = 'Error de conexión con el proveedor de pago. Por favor, inténtalo de nuevo.'; +$_MODULE['<{twopayment}prestashop>twopayment_6c04b086d2a5eed8ae6b2beca8a4221f'] = 'El proveedor de pago no está disponible temporalmente. Por favor, inténtalo más tarde.'; +$_MODULE['<{twopayment}prestashop>twopayment_de62177e8fda33fee61addda5ea9eeb3'] = 'Resuelve el problema de pago antes de continuar.'; $_MODULE['<{twopayment}prestashop>twopayment_0d8dfbcfe22598503672351254cbe691'] = 'Se requiere la aprobación del pago antes de continuar'; +$_MODULE['<{twopayment}prestashop>twopayment_c14c68d27f136772295166d1de2244ad'] = 'Tu factura con Two no puede ser aprobada en este momento. Por favor, selecciona otro método de pago.'; +$_MODULE['<{twopayment}prestashop>twopayment_c98c1c103299aa7980dcfbe5309a812e'] = 'La dirección de correo electrónico proporcionada no es válida. Por favor, revisa tu correo y vuelve a intentarlo.'; +$_MODULE['<{twopayment}prestashop>twopayment_144b4dc20a476a4588ff5f481283cfc6'] = 'La dirección proporcionada no es válida. Por favor, vuelve atrás y verifica los datos de tu dirección de facturación.'; +$_MODULE['<{twopayment}prestashop>twopayment_22008c36020d2d76ff6ff3c988b4fa58'] = 'La información de la empresa está incompleta. Vuelve a tu dirección de facturación y selecciona tu empresa de los resultados de búsqueda.'; +$_MODULE['<{twopayment}prestashop>twopayment_d314a00556eb88a960abf8a0de8b45f8'] = 'Algunos de los datos proporcionados no son válidos. Por favor, revisa los detalles de tu dirección de facturación y vuelve a intentarlo.'; +$_MODULE['<{twopayment}prestashop>twopayment_fa8a347b2b0ab6e45116e184b464fe5c'] = 'No se pudo verificar la información de la empresa. Vuelve a tu dirección de facturación y selecciona tu empresa de los resultados de búsqueda.'; +$_MODULE['<{twopayment}prestashop>twopayment_1451451832b090e38c24860ea609e5a8'] = 'Verificación de empresa necesaria'; +$_MODULE['<{twopayment}prestashop>twopayment_fa2ec5f818605595f78e7fc164a772be'] = 'Hemos encontrado el nombre de tu empresa, pero necesitamos que la verifiques. Vuelve a tu dirección de facturación y selecciona tu empresa en los resultados de búsqueda.'; $_MODULE['<{twopayment}prestashop>twopayment_b4fe334d1b7cdcbc01db8426803ebaff'] = 'Pagar en'; $_MODULE['<{twopayment}prestashop>twopayment_44fdec47036f482b68b748f9d786801b'] = 'días'; $_MODULE['<{twopayment}prestashop>twopayment_ad390087f94a9a17adaf0b81ad83b2e7'] = 'desde el fin de mes'; +$_MODULE['<{twopayment}prestashop>twopayment_65facdf395107d60a23ce012c2a1c456'] = 'Fin de mes + %s días'; $_MODULE['<{twopayment}prestashop>twopayment_03ac21a9797a06114f0aedd391a1e2a3'] = 'No se encontraron resultados'; $_MODULE['<{twopayment}prestashop>twopayment_00589e3bb6d7bbb598f17fe9bfe70052'] = 'Número de teléfono no válido'; $_MODULE['<{twopayment}prestashop>twopayment_06e96958c3ac68d916db7da58cdbb5c9'] = 'Código de país no válido'; @@ -128,6 +225,10 @@ $_MODULE['<{twopayment}prestashop>twopayment_2b4ef6e3a316295ab176b9dae7ef46b6'] = 'Coste de envío del pedido'; $_MODULE['<{twopayment}prestashop>twopayment_9091655deaca780040e501e02a1805b2'] = '(por peso)'; $_MODULE['<{twopayment}prestashop>twopayment_329aa8486f4fde54cc4c9965faea9aa9'] = '(por precio)'; +$_MODULE['<{twopayment}prestashop>twopayment_e92cfa244b5eb9025d07522080468445'] = 'Ecotasa'; +$_MODULE['<{twopayment}prestashop>twopayment_893c937ba17594e25cd9b8a6baa9a923'] = 'Impuesto medioambiental (ecotasa)'; +$_MODULE['<{twopayment}prestashop>twopayment_484f5a79672cebe198ebdde45a1d672f'] = 'Envoltorio de regalo'; +$_MODULE['<{twopayment}prestashop>twopayment_b4d559d50e616b7eb46089c0734d1b1c'] = 'Envoltorio de regalo para este pedido'; $_MODULE['<{twopayment}prestashop>twopayment_104d9898c04874d0fbac36e125fa1369'] = 'Descuento'; $_MODULE['<{twopayment}prestashop>twopayment_82ab2bbfea5681899d51358644b7e5b0'] = 'Descuento del pedido'; $_MODULE['<{twopayment}prestashop>twopayment_736a2c997a0aeb75cd77b06f9a7b6934'] = 'Descuento: %s'; @@ -135,32 +236,67 @@ $_MODULE['<{twopayment}prestashop>twopayment_2e92ae79ff32b37fee4368a594792183'] = 'Error de conexión'; $_MODULE['<{twopayment}prestashop>twopayment_3c8437f18f50552a8624846ce272e57f'] = 'Ha ocurrido un error, por favor contacta con el propietario de la tienda.'; $_MODULE['<{twopayment}prestashop>twopayment_66e6e619e3e72bb941b0d2d4947466c9'] = 'Código de respuesta de Two %d'; -$_MODULE['<{twopayment}prestashop>twopayment_c98c1c103299aa7980dcfbe5309a812e'] = 'La dirección de correo electrónico proporcionada no es válida. Por favor, revisa tu correo y vuelve a intentarlo.'; $_MODULE['<{twopayment}prestashop>twopayment_d32e67e619603fc5368d087716b1afb4'] = 'La información de la empresa proporcionada no es válida. Por favor, vuelve a la dirección de facturación y busca el nombre de tu empresa para seleccionar una empresa válida.'; -$_MODULE['<{twopayment}prestashop>twopayment_144b4dc20a476a4588ff5f481283cfc6'] = 'La dirección proporcionada no es válida. Por favor, vuelve atrás y verifica los datos de tu dirección de facturación.'; -$_MODULE['<{twopayment}prestashop>twopayment_d314a00556eb88a960abf8a0de8b45f8'] = 'Algunos de los datos proporcionados no son válidos. Por favor, revisa los detalles de tu dirección de facturación y vuelve a intentarlo.'; -$_MODULE['<{twopayment}prestashop>cancel_67205cb0f855142963b4901fed7b35de'] = 'No se pudo actualizar el estado a cancelado, por favor revisa con el administrador de Two para el id %s'; -$_MODULE['<{twopayment}prestashop>cancel_693d87a10d7bf7577f44c12dfecb0559'] = 'Tu pedido ha sido cancelado.'; +$_MODULE['<{twopayment}prestashop>twopayment_80a7a49c92287a2654c268aa8afda4d6'] = 'Cumplimiento bloqueado: este pedido de Two está cancelado en el proveedor. El estado del pedido se ha revertido a cancelado.'; $_MODULE['<{twopayment}prestashop>cancel_a18b98ac865c5099d00d15aa7955772e'] = 'No se pudo encontrar el pedido solicitado, por favor contacta con el propietario de la tienda.'; -$_MODULE['<{twopayment}prestashop>confirmation_4b63989fed5f90859fafd6e1f02be539'] = 'No se pudo recuperar la información de pago del pedido, por favor contacta con el propietario de la tienda.'; +$_MODULE['<{twopayment}prestashop>cancel_27a33767ea5cfba88612709325b07afa'] = 'No se pudo encontrar el intento de pago solicitado.'; +$_MODULE['<{twopayment}prestashop>cancel_68fc97fb527fe6d18d12d02199710634'] = 'No se pudo validar esta notificación de cancelación. Vuelve a intentarlo en el proceso de pago.'; +$_MODULE['<{twopayment}prestashop>cancel_693d87a10d7bf7577f44c12dfecb0559'] = 'Tu pedido ha sido cancelado.'; +$_MODULE['<{twopayment}prestashop>cancel_0ad05b9020f4d78e45efdacdb76e734b'] = 'No se pudo cargar el cliente del pedido.'; +$_MODULE['<{twopayment}prestashop>cancel_67205cb0f855142963b4901fed7b35de'] = 'No se pudo actualizar el estado a cancelado, por favor revisa con el administrador de Two para el id %s'; $_MODULE['<{twopayment}prestashop>confirmation_a18b98ac865c5099d00d15aa7955772e'] = 'No se pudo encontrar el pedido solicitado, por favor contacta con el propietario de la tienda.'; +$_MODULE['<{twopayment}prestashop>confirmation_28c0653584b0c1c8ce2da709ece6a973'] = 'No se pudo encontrar el intento de pago solicitado. Vuelve a intentarlo en el proceso de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_6d5cc1a38d6228cd43fb864b6c4d4b75'] = 'No se pudo validar esta notificación de pago. Vuelve a intentarlo en el proceso de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_59fd644b4843f1a88ce811259d9eae3d'] = 'No se pudo cargar el cliente para este intento de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_9d8c5a3cd608d5590ab3da22a6855f83'] = 'No se pudo cargar el carrito para este intento de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_12dbe8e446cc6067de4c47a0f3f6d78d'] = 'Falta la referencia del pedido del proveedor para este intento.'; +$_MODULE['<{twopayment}prestashop>confirmation_633918382ee5b43840752588882a0496'] = 'No se pudo validar la consistencia del carrito para este pago. Inténtalo de nuevo.'; +$_MODULE['<{twopayment}prestashop>confirmation_1710ee7af07f5640bdb52b05df988209'] = 'Tu carrito cambió durante la verificación del pago. Revisa tu carrito y vuelve a intentarlo.'; +$_MODULE['<{twopayment}prestashop>confirmation_4b63989fed5f90859fafd6e1f02be539'] = 'No se pudo recuperar la información de pago del pedido, por favor contacta con el propietario de la tienda.'; +$_MODULE['<{twopayment}prestashop>confirmation_3727d6bec90c4328e4aa9f542668fb4a'] = 'El pago aún no se ha verificado. Inténtalo de nuevo o contacta con soporte.'; +$_MODULE['<{twopayment}prestashop>confirmation_11503989a3f470d727d38a6054896940'] = 'No se pudo cargar la moneda para este intento de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_87265a2a96ebb4450eb89400cecdac4c'] = 'No se pudo crear el pedido local para este intento de pago.'; +$_MODULE['<{twopayment}prestashop>confirmation_8725e3093da6c77204f4212866d6cdc1'] = 'Este intento de pago ya se ha finalizado.'; +$_MODULE['<{twopayment}prestashop>confirmation_a49e05d4125474b3a6cfc0caacc5a48f'] = 'No se pudo cargar el pedido creado. Contacta con soporte.'; +$_MODULE['<{twopayment}prestashop>confirmation_0ad05b9020f4d78e45efdacdb76e734b'] = 'No se pudo cargar el cliente del pedido.'; +$_MODULE['<{twopayment}prestashop>confirmation_693d87a10d7bf7577f44c12dfecb0559'] = 'Tu pedido ha sido cancelado.'; $_MODULE['<{twopayment}prestashop>payment_3b8d62101f4aa1d63f9f4fb18f32f193'] = 'Para pagar con Two, selecciona tu empresa para que podamos verificar tu negocio y ofrecerte plazos de factura.'; $_MODULE['<{twopayment}prestashop>payment_e2b7dec8fa4b498156dfee6e4c84b156'] = 'Este método de pago no está disponible.'; $_MODULE['<{twopayment}prestashop>payment_27347f1b2aaf71eb152357081a0820b5'] = 'El cliente no es válido.'; $_MODULE['<{twopayment}prestashop>payment_36db71f088bd580af6416fa507d77a58'] = 'Tu pedido no pudo ser aprobado por el pago con Two. Por favor, elige otro método de pago o contacta con el soporte.'; $_MODULE['<{twopayment}prestashop>payment_9c51eb50fb79bffa2c795ac4773781dc'] = 'La aprobación de tu pago ha expirado. Actualiza la página e inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_990f982c41ce66c911e505fb58fa8e46'] = 'No se puede procesar tu pedido con el pago de Two.'; +$_MODULE['<{twopayment}prestashop>payment_8dd557fb0a33b5d844cdf7b87e84f59a'] = 'Este método de pago no está disponible para la moneda seleccionada.'; +$_MODULE['<{twopayment}prestashop>payment_2ead200be9922b9284cb1324bea07c33'] = 'No se puede procesar tu pedido con el pago de Two. Revisa tu carrito e inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_0e59e4dd7c8466ff99df71f1e49cbfa1'] = 'Error de conexión con el proveedor de pago. Por favor, inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_d83e9ff3f8fd11e651a6f27f40dcf82b'] = 'Error en la configuración del método de pago. Por favor, contacta con la tienda.'; $_MODULE['<{twopayment}prestashop>payment_f962210107c086dcb022c580cdd04c72'] = 'Datos de pedido no válidos. Revisa tus datos e inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_6c04b086d2a5eed8ae6b2beca8a4221f'] = 'El proveedor de pago no está disponible temporalmente. Por favor, inténtalo más tarde.'; +$_MODULE['<{twopayment}prestashop>payment_564b2a8eaf613ba0e677a506502a2c14'] = 'Problema temporal en el proceso de pago. Inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_dec67bcfdcf3eaa74a37b13df30c8ce5'] = 'No es posible procesar tu pago en este momento. Por favor, contacta con el propietario de la tienda para obtener ayuda.'; +$_MODULE['<{twopayment}prestashop>payment_8ae26445494e68f399026e73ed477c13'] = 'No se pudo redirigir al proveedor de pago. Inténtalo de nuevo.'; $_MODULE['<{twopayment}prestashop>payment_3c8437f18f50552a8624846ce272e57f'] = 'Ha ocurrido un error, por favor contacta con el propietario de la tienda.'; $_MODULE['<{twopayment}prestashop>orderintent_93ef8e5b106e50d55fd88d4a966668c8'] = 'Para pagar con Two, vuelve a tu dirección de facturación e introduce el nombre de tu empresa en el campo «Empresa».'; $_MODULE['<{twopayment}prestashop>orderintent_0283fa93feb690f8b3537e18ed6bb4ab'] = 'Para pagar con Two, vuelve a tu dirección de facturación y busca el nombre de tu empresa. Selecciona tu empresa en los resultados para verificar tu negocio.'; +$_MODULE['<{twopayment}prestashop>orderintent_371c6879002f6a2c9c0ce14144a06265'] = 'Acción solicitada no válida.'; +$_MODULE['<{twopayment}prestashop>orderintent_607e1d854783c8229998ac2b5b6923d3'] = 'Token no válido.'; +$_MODULE['<{twopayment}prestashop>orderintent_60b60d57ac14700787432034bf58f8b1'] = 'Solo se permiten solicitudes POST.'; +$_MODULE['<{twopayment}prestashop>orderintent_5669d9571e3babb8fc7c4667727767be'] = 'Cantidad de días no válida.'; +$_MODULE['<{twopayment}prestashop>orderintent_071029e1649d80c309fca4ed11ed4752'] = 'Faltan datos de la empresa.'; +$_MODULE['<{twopayment}prestashop>orderintent_7d211a5d9cccb0df11ba75b9db2e28a5'] = 'Demasiadas solicitudes. Espera un momento y vuelve a intentarlo.'; +$_MODULE['<{twopayment}prestashop>orderintent_a5428d68edede974fe71ad98080b8b5d'] = 'Datos de carrito o cliente no válidos.'; +$_MODULE['<{twopayment}prestashop>orderintent_e097593ef29856ae66e16694e76af874'] = 'El pago con Two solo está disponible para cuentas empresariales.'; +$_MODULE['<{twopayment}prestashop>orderintent_4f40188f22dbc3f6b5f5ea06152782ca'] = 'No se pudo generar la carga útil de Order Intent.'; +$_MODULE['<{twopayment}prestashop>twopayment_d540e424b208e0c024a497d3b0943502'] = 'Cuando está activado, los pedidos se marcan automáticamente como completados en Two cuando su estado cambia a uno de los estados de activación de cumplimiento que hayas configurado (ver Mapeo de estados de pedido). Esto activa los plazos de pago del comprador e inicia el ciclo de cobro. Si está desactivado, debes completar los pedidos manualmente en el portal de comerciantes de Two.'; +$_MODULE['<{twopayment}prestashop>twopayment_35e84df88a2aa8846a4a93cb66fa71bb'] = 'Actívalo SOLO si utilizas tus propias facturas en lugar de las facturas generadas por Two. Debe coordinarse con Two antes de activarlo.'; +$_MODULE['<{twopayment}prestashop>twopayment_dbb3d7e84474bdf7d44bfc20d73452d0'] = 'Edita tu plantilla de factura para incluir los datos de pago de Two SOLO PARA PEDIDOS DE TWO.'; +$_MODULE['<{twopayment}prestashop>twopayment_6552cb814030c8273f57fca901a9e6db'] = 'Anular la decisión de crédito de Two o los límites del comprador'; +$_MODULE['<{twopayment}prestashop>twopayment_f423c26e0163f13230f2eea772cf7850'] = 'La empresa puede haber alcanzado su límite de crédito o no haber superado la comprobación de crédito de Two.'; +$_MODULE['<{twopayment}prestashop>twopayment_43183e955e3019bf7f8c942e016b7b13'] = 'IVA'; $_MODULE['<{twopayment}prestashop>configuration_52f4393e1b52ba63e27310ca92ba098c'] = 'Configuración general'; $_MODULE['<{twopayment}prestashop>configuration_a8ad8ed0d7a57bafaf4fa86fda0dd87f'] = 'Otras configuraciones'; $_MODULE['<{twopayment}prestashop>configuration_13831bd312b782daa4e11738a2fe3d04'] = 'Configuración de estados de pedido'; +$_MODULE['<{twopayment}prestashop>configuration_4b89abd8c5879f053b551487a3b7c1c3'] = 'Información del plugin'; $_MODULE['<{twopayment}prestashop>configuration_3f68e67dc6c397aaa9d1c24c356f754f'] = 'Verificado'; $_MODULE['<{twopayment}prestashop>configuration_daf40c1ea5990bdee77b09f7137ffc31'] = 'Clave API verificada correctamente'; $_MODULE['<{twopayment}prestashop>configuration_229a7ec501323b94db7ff3157a7623c9'] = 'ID del comerciante'; @@ -192,6 +328,8 @@ $_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_b9484b0275e32e5b1011b9e3a49ce19a'] = 'Pago con Two'; $_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_7de46cb469a9606c3f20c512df2519f3'] = 'Plazos de la factura'; $_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_cdca48ec5dde79b84b395c0c557bb290'] = '%d días'; +$_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_64fb978fc0b2296e92bfe159b00b80cf'] = 'Fin de mes + %d días'; +$_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_7f9bf3875603f517e5ea51fcebed6ca8'] = 'Estándar + %d días'; $_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_f23b2c9fe4875f2c3dbc61a4bd02705b'] = 'Portal del comprador Two'; $_MODULE['<{twopayment}prestashop>displaypaymentreturnbuyer_eca5a3449475f365c129a02789d64a36'] = 'Accede a tu portal de comprador de Two para ver este pedido una vez completado'; $_MODULE['<{twopayment}prestashop>displayadminordertablink_4aa713eb466d04cbbf55ce1bc172294c'] = 'Detalles del pago Two'; @@ -214,6 +352,9 @@ $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_f3a5551033aab2c2c4292edbc3f73e77'] = 'Fin de mes'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_44fdec47036f482b68b748f9d786801b'] = 'días'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_c4741fef1acd14b09b4c149b7991edff'] = 'Pago vencido: al final del mes actual al cumplirse el pedido + días del plazo de pago'; +$_MODULE['<{twopayment}prestashop>displayadminordertabcontent_d2d043e96b6e4b38e8c5d0b384918b23'] = 'No registrado para este pedido'; +$_MODULE['<{twopayment}prestashop>displayadminordertabcontent_a4afce6a15b842246e5ab7a7f2e68f51'] = 'Estado en Two'; +$_MODULE['<{twopayment}prestashop>displayadminordertabcontent_2fdeffc575b8052f195eeff5b113cf3c'] = 'Estado de Two'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_244378591601d6b08b7a51ba5f1a11de'] = 'Estado de carga de la factura'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_eb475fe60ff4d654b789fce182ca6fce'] = 'Estado de carga'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_fe8d588f340d7507265417633ccff16e'] = 'Cargado'; @@ -231,67 +372,19 @@ $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_801ab24683a4a8c433c6eb40c48bcd9d'] = 'Descargar'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_7cf626ac15afd59a810ec768f6d1b767'] = 'URL de la factura'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_d755f2315905c3dbc605d4b56c9ef974'] = 'Abrir enlace'; +$_MODULE['<{twopayment}prestashop>displayadminordertabcontent_d51de02c2c2fd72cdfcc087f0222b51a'] = 'Enlaces de factura'; +$_MODULE['<{twopayment}prestashop>displayadminordertabcontent_30bdc6a4a7574397b218004e16544291'] = 'Disponible tras el cumplimiento del pedido en Two'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_eba8454706fdb46ec05a07f3d82705bf'] = 'Portal Two'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_e074967d2a6722d4b4bec37f907097ae'] = 'Información del pago Two'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_674d9f62330449287efd1512e05fbb43'] = 'ID de pedido Two'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_63d5049791d9d79d86e9a108b0a999ca'] = 'Referencia'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_3ec74eaa839c4e1851706ef6709dfbbb'] = 'Plazos de pago'; +$_MODULE['<{twopayment}prestashop>displayadminorderleft_f3a5551033aab2c2c4292edbc3f73e77'] = 'Fin de mes'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_44fdec47036f482b68b748f9d786801b'] = 'días'; +$_MODULE['<{twopayment}prestashop>displayadminorderleft_d2d043e96b6e4b38e8c5d0b384918b23'] = 'No registrado para este pedido'; +$_MODULE['<{twopayment}prestashop>displayadminorderleft_a4afce6a15b842246e5ab7a7f2e68f51'] = 'Estado en Two'; +$_MODULE['<{twopayment}prestashop>displayadminorderleft_2fdeffc575b8052f195eeff5b113cf3c'] = 'Estado de Two'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_a89e7ebb73476ac3150d26979f1c832d'] = 'Ver en el portal'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_e468fef315b479c151460e6174ce9782'] = 'Descargar factura'; $_MODULE['<{twopayment}prestashop>displayadminorderleft_7cf626ac15afd59a810ec768f6d1b767'] = 'URL de la factura'; -$_MODULE['<{twopayment}prestashop>twopayment_c14c68d27f136772295166d1de2244ad'] = 'Tu factura con Two no puede ser aprobada en este momento. Por favor, selecciona otro método de pago.'; -$_MODULE['<{twopayment}prestashop>twopayment_22008c36020d2d76ff6ff3c988b4fa58'] = 'La información de la empresa está incompleta. Vuelve a tu dirección de facturación y selecciona tu empresa de los resultados de búsqueda.'; -$_MODULE['<{twopayment}prestashop>twopayment_fa8a347b2b0ab6e45116e184b464fe5c'] = 'No se pudo verificar la información de la empresa. Vuelve a tu dirección de facturación y selecciona tu empresa de los resultados de búsqueda.'; - -// Plugin Information Tab - v2.3.1 -$_MODULE['<{twopayment}prestashop>configuration_caborned78c4ed9e32ec8c1c3d96e71b2'] = 'Información del plugin'; -$_MODULE['<{twopayment}prestashop>twopayment_9d4a9fa4015f1bdc8eb9c86a1a717626'] = 'Qué hace este plugin'; -$_MODULE['<{twopayment}prestashop>twopayment_1bf4e18dae6a6d79af2fd0b6f4ba7f23'] = 'Two es una solución B2B de Compra ahora, Paga después'; -$_MODULE['<{twopayment}prestashop>twopayment_68c9a06ba5c32f9f5cd8e9ec0f5b3a82'] = 'Este plugin permite a los clientes empresariales pagar con factura con decisiones de crédito instantáneas.'; -$_MODULE['<{twopayment}prestashop>twopayment_7c1e56a8647c17d57d71a0264b4e6f77'] = 'Lo que el plugin PUEDE hacer'; -$_MODULE['<{twopayment}prestashop>twopayment_a1b89c2d4f5e6a7b8c9d0e1f2a3b4c5d'] = 'Aceptar pagos B2B con factura con aprobación de crédito instantánea'; -$_MODULE['<{twopayment}prestashop>twopayment_b2c90d3e5f6a7b8c9d0e1f2a3b4c5d6e'] = 'Búsqueda y validación de empresas en el checkout (autocompletado)'; -$_MODULE['<{twopayment}prestashop>twopayment_c3d01e4f6a7b8c9d0e1f2a3b4c5d6e7f'] = 'Verificación de elegibilidad del comprador en tiempo real (Order Intent) antes de la compra'; -$_MODULE['<{twopayment}prestashop>twopayment_d4e12f5a7b8c9d0e1f2a3b4c5d6e7f8a'] = 'Cumplimiento automático de pedidos cuando cambia el estado (configurable)'; -$_MODULE['<{twopayment}prestashop>twopayment_e5f23a6b8c9d0e1f2a3b4c5d6e7f8a9b'] = 'Soporte para plazos de pago Estándar y Fin de Mes (EOM)'; -$_MODULE['<{twopayment}prestashop>twopayment_f6a34b7c9d0e1f2a3b4c5d6e7f8a9b0c'] = 'Plazos de pago configurables (7, 15, 20, 30, 45, 60, 90 días)'; -$_MODULE['<{twopayment}prestashop>twopayment_a7b45c8d0e1f2a3b4c5d6e7f8a9b0c1d'] = 'Gestionar reembolsos completos desde el admin de PrestaShop'; -$_MODULE['<{twopayment}prestashop>twopayment_b8c56d9e1f2a3b4c5d6e7f8a9b0c1d2e'] = 'Mostrar información del pedido Two en la vista de pedidos del admin'; -$_MODULE['<{twopayment}prestashop>twopayment_c9d67e0f2a3b4c5d6e7f8a9b0c1d2e3f'] = 'Soporte para múltiples tasas de impuestos y clientes exentos de impuestos'; -$_MODULE['<{twopayment}prestashop>twopayment_d0e78f1a3b4c5d6e7f8a9b0c1d2e3f4a'] = 'Gestionar correctamente reglas de envío gratuito y descuentos'; -$_MODULE['<{twopayment}prestashop>twopayment_e1f89a2b4c5d6e7f8a9b0c1d2e3f4a5b'] = 'Funciona con PrestaShop 1.7.6 hasta 9.x'; -$_MODULE['<{twopayment}prestashop>twopayment_f2a90b3c5d6e7f8a9b0c1d2e3f4a5b6c'] = 'Requisitos importantes'; -$_MODULE['<{twopayment}prestashop>twopayment_a3b01c4d6e7f8a9b0c1d2e3f4a5b6c7d'] = 'Los clientes deben tener un número de empresa/organización válido'; -$_MODULE['<{twopayment}prestashop>twopayment_b4c12d5e7f8a9b0c1d2e3f4a5b6c7d8e'] = 'Los clientes deben introducir el nombre de su empresa en la dirección de facturación'; -$_MODULE['<{twopayment}prestashop>twopayment_c5d23e6f8a9b0c1d2e3f4a5b6c7d8e9f'] = 'Se requiere un número de teléfono válido para las verificaciones de crédito'; -$_MODULE['<{twopayment}prestashop>twopayment_d6e34f7a9b0c1d2e3f4a5b6c7d8e9f0a'] = 'Two debe aprobar al comprador antes de que se pueda realizar el pedido'; -$_MODULE['<{twopayment}prestashop>twopayment_e7f45a8b0c1d2e3f4a5b6c7d8e9f0a1b'] = 'Los productos deben tener las reglas de impuestos correctas configuradas en PrestaShop'; -$_MODULE['<{twopayment}prestashop>twopayment_f8a56b9c1d2e3f4a5b6c7d8e9f0a1b2c'] = 'Lo que el plugin NO PUEDE hacer'; -$_MODULE['<{twopayment}prestashop>twopayment_a9b67c0d2e3f4a5b6c7d8e9f0a1b2c3d'] = 'Procesar pagos B2C (consumidor) - Two es solo B2B'; -$_MODULE['<{twopayment}prestashop>twopayment_b0c78d1e3f4a5b6c7d8e9f0a1b2c3d4e'] = 'Garantizar la aprobación - Two realiza verificaciones de crédito en tiempo real'; -$_MODULE['<{twopayment}prestashop>twopayment_c1d89e2f4a5b6c7d8e9f0a1b2c3d4e5f'] = 'Anular la decisión de crédito o los límites del comprador de Two'; -$_MODULE['<{twopayment}prestashop>twopayment_g1h23i4j5k6l7m8n9o0p1q2r3s4t5u6v'] = 'Procesar reembolsos parciales - usa el Portal de Comerciantes de Two para reembolsos parciales'; -$_MODULE['<{twopayment}prestashop>twopayment_h2i34j5k6l7m8n9o0p1q2r3s4t5u6v7w'] = 'Cumplimiento parcial - los pedidos deben cumplirse en su totalidad'; -$_MODULE['<{twopayment}prestashop>twopayment_d2e90f3a5b6c7d8e9f0a1b2c3d4e5f6a'] = 'Corregir configuración de impuestos incorrecta en tu tienda - los impuestos deben estar configurados correctamente en PrestaShop'; -$_MODULE['<{twopayment}prestashop>twopayment_e3f01a4b6c7d8e9f0a1b2c3d4e5f6a7b'] = 'Procesar pedidos sin un número de registro de empresa válido'; -$_MODULE['<{twopayment}prestashop>twopayment_f4a12b5c7d8e9f0a1b2c3d4e5f6a7b8c'] = 'Cambiar los plazos de pago después de realizar un pedido'; -$_MODULE['<{twopayment}prestashop>twopayment_a5b23c6d8e9f0a1b2c3d4e5f6a7b8c9d'] = 'Consejos de solución de problemas'; -$_MODULE['<{twopayment}prestashop>twopayment_b6c34d7e9f0a1b2c3d4e5f6a7b8c9d0e'] = '¿El impuesto muestra 0%?'; -$_MODULE['<{twopayment}prestashop>twopayment_c7d45e8f0a1b2c3d4e5f6a7b8c9d0e1f'] = 'Verifica que las reglas de impuestos estén configuradas para tu país en Internacional > Impuestos > Reglas de impuestos'; -$_MODULE['<{twopayment}prestashop>twopayment_d8e56f9a1b2c3d4e5f6a7b8c9d0e1f2a'] = '¿Comprador rechazado?'; -$_MODULE['<{twopayment}prestashop>twopayment_e9f67a0b2c3d4e5f6a7b8c9d0e1f2a3b'] = 'La empresa puede haber alcanzado su límite de crédito o no haber pasado la verificación de crédito de Two'; -$_MODULE['<{twopayment}prestashop>twopayment_f0a78b1c3d4e5f6a7b8c9d0e1f2a3b4c'] = '¿Empresa no encontrada?'; -$_MODULE['<{twopayment}prestashop>twopayment_a1b89c2d4e5f6a7b8c9d0e1f2a3b4c5e'] = 'El cliente debe introducir el nombre oficial registrado de su empresa'; -$_MODULE['<{twopayment}prestashop>twopayment_b2c90d3e5f6a7b8c9d0e1f2a3b4c5d6f'] = '¿Teléfono no válido?'; -$_MODULE['<{twopayment}prestashop>twopayment_c3d01e4f6a7b8c9d0e1f2a3b4c5d6e7g'] = 'Asegúrate de que el número de teléfono incluya el código de país y esté en un formato válido'; -$_MODULE['<{twopayment}prestashop>twopayment_d4e12f5a7b8c9d0e1f2a3b4c5d6e7f8b'] = '¿Errores de discrepancia de importes?'; -$_MODULE['<{twopayment}prestashop>twopayment_e5f23a6b8c9d0e1f2a3b4c5d6e7f8a9c'] = 'Activa el Modo de depuración en Otras configuraciones y contacta con el soporte de Two con los registros'; -$_MODULE['<{twopayment}prestashop>twopayment_f6a34b7c9d0e1f2a3b4c5d6e7f8a9b0d'] = '¿Necesitas ayuda?'; -$_MODULE['<{twopayment}prestashop>twopayment_a7b45c8d0e1f2a3b4c5d6e7f8a9b0c1e'] = 'Para soporte técnico o preguntas sobre este plugin:'; -$_MODULE['<{twopayment}prestashop>twopayment_b8c56d9e1f2a3b4c5d6e7f8a9b0c1d2f'] = 'Email:'; -$_MODULE['<{twopayment}prestashop>twopayment_c9d67e0f2a3b4c5d6e7f8a9b0c1d2e3g'] = 'Documentación:'; -$_MODULE['<{twopayment}prestashop>twopayment_d0e78f1a3b4c5d6e7f8a9b0c1d2e3f4b'] = 'Portal del comerciante:'; -$_MODULE['<{twopayment}prestashop>twopayment_e1f89a2b4c5d6e7f8a9b0c1d2e3f4a5c'] = 'Abrir el portal de Two'; -$_MODULE['<{twopayment}prestashop>twopayment_f2a90b3c5d6e7f8a9b0c1d2e3f4a5b6d'] = 'Versión del plugin:'; -$_MODULE['<{twopayment}prestashop>twopayment_a3b01c4d6e7f8a9b0c1d2e3f4a5b6c7e'] = 'PrestaShop:'; \ No newline at end of file +$_MODULE['<{twopayment}prestashop>displayadminorderleft_11335f7aaa473442d803c43c8b8d804d'] = 'Los enlaces de factura estarán disponibles cuando el pedido de Two se haya completado.'; diff --git a/twopayment.php b/twopayment.php index 843832f..fc22dd2 100644 --- a/twopayment.php +++ b/twopayment.php @@ -26,8 +26,19 @@ class Twopayment extends PaymentModule const API_TIMEOUT_LONG = 60; // Extended timeout for file uploads // Constants for validation tolerances - const TAX_FORMULA_TOLERANCE = 0.01; // Tolerance for tax formula validation + const TAX_FORMULA_TOLERANCE = 0.02; // Tolerance for tax formula validation const NET_FORMULA_TOLERANCE = 0.05; // Tolerance for net formula validation + const ORDER_RECONCILIATION_TOLERANCE = 0.02; // Warn-level parity tolerance against cart totals (PrestaShop rounding can drift by up to 2 cents) + const TAX_RATE_PRECISION = 3; // Decimal precision for line-item tax rates sent to Two + const TAX_SUBTOTAL_RATE_PRECISION = 2; // Keep tax subtotal grouping stable for compatibility + const SNAPSHOT_TAX_RATE_PRECISION = 2; // Keep snapshot hash behavior stable across minor rate precision drift + const TAX_RATE_PERCENT_PRECISION = 2; // Provider expects VAT rates rounded to 2 decimals in percent + const TAX_RATE_VARIANCE_TOLERANCE = 0.005; // Acceptable decimal variance between configured and applied rate + const TAX_RATE_CONTEXT_SNAP_TOLERANCE = 0.0025; // Snap near-context discount rates (e.g. 0.212 -> 0.21) for provider compatibility + const SPANISH_FALLBACK_TAX_RATE = 0.21; // ES strict fallback when unresolved line rates drift from canonical contexts + // Two module currency coverage baseline: keep these provider currencies explicitly allowed. + // Required coverage: NOK, GBP, SEK, USD, DKK, EUR + const TWO_SUPPORTED_CURRENCY_ISOS = ['NOK', 'GBP', 'SEK', 'USD', 'DKK', 'EUR']; // Constants for delivery dates const DEFAULT_DELIVERY_DAYS_OFFSET = 7; // Default expected delivery date offset @@ -40,6 +51,8 @@ class Twopayment extends PaymentModule // Constants for cookie/session expiry (seconds) const COOKIE_EXPIRY_ONE_HOUR = 3600; // 1 hour + const ATTEMPT_RETENTION_DAYS = 90; // Keep attempt telemetry for 90 days + const ATTEMPT_CLEANUP_INTERVAL_SECONDS = 86400; // Run cleanup at most once per day protected $output = ''; protected $errors = array(); @@ -50,7 +63,7 @@ public function __construct() { $this->name = 'twopayment'; $this->tab = 'payments_gateways'; - $this->version = '2.3.2'; + $this->version = '2.4.0'; $this->ps_versions_compliancy = array('min' => '1.7.6.0', 'max' => _PS_VERSION_); $this->author = 'Two'; $this->bootstrap = true; @@ -66,12 +79,14 @@ public function __construct() $this->enable_company_id = Configuration::get('PS_TWO_ENABLE_COMPANY_ID'); $this->enable_department = Configuration::get('PS_TWO_ENABLE_DEPARTMENT'); $this->enable_project = Configuration::get('PS_TWO_ENABLE_PROJECT'); - $this->enable_order_intent = Configuration::get('PS_TWO_ENABLE_ORDER_INTENT'); + // Order intent pre-check is mandatory for all checkouts. + $this->enable_order_intent = 1; $this->use_account_type = Configuration::get('PS_TWO_USE_ACCOUNT_TYPE'); $this->finalize_purchase_shipping = Configuration::get('PS_TWO_FINALIZE_PURCHASE'); // Ensure custom Two states exist (for existing installations) $this->ensureCustomStatesExist(); + $this->ensureRequiredHooksRegistered(); } /** @@ -96,6 +111,26 @@ private function ensureCustomStatesExist() } } } + + /** + * Register newly introduced hooks on existing installations. + */ + private function ensureRequiredHooksRegistered() + { + if ((int)$this->id <= 0 || !Module::isInstalled($this->name)) { + return; + } + + $required_hooks = array( + 'actionObjectOrderHistoryAddBefore', + ); + + foreach ($required_hooks as $hook_name) { + if (!$this->isRegisteredInHook($hook_name)) { + $this->registerHook($hook_name); + } + } + } public function install() @@ -108,6 +143,7 @@ public function install() $this->registerHook('actionAdminControllerSetMedia') && $this->registerHook('actionFrontControllerSetMedia') && $this->registerHook('actionOrderStatusUpdate') && + $this->registerHook('actionObjectOrderHistoryAddBefore') && $this->registerHook('paymentOptions') && $this->registerHook('displayPaymentReturn') && $this->registerHook('displayAdminOrderLeft') && @@ -141,11 +177,11 @@ protected function installTwoSettings() Configuration::updateValue('PS_TWO_ENABLE_COMPANY_NAME', 1); Configuration::updateValue('PS_TWO_ENABLE_COMPANY_ID', 1); Configuration::updateValue('PS_TWO_FINALIZE_PURCHASE', 1); - Configuration::updateValue('PS_TWO_ENABLE_ORDER_INTENT', 1); Configuration::updateValue('PS_TWO_USE_ACCOUNT_TYPE', 0); Configuration::updateValue('PS_TWO_USE_OWN_INVOICES', 0); // Disabled by default - must be enabled after coordinating with Two Configuration::updateValue('PS_TWO_PAYMENT_TERM_TYPE', 'STANDARD'); // Default: Standard payment terms (not EOM) Configuration::updateValue('PS_TWO_PAYMENT_TERMS_30', 1); // Default: 30 days enabled + Configuration::updateValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 1); // Enabled by default; can be disabled for compatibility // Custom Two order states will be created by createTwoOrderState() // Set sensible default mappings to standard PrestaShop states // Processing states default to their Two-branded states out-of-the-box @@ -261,7 +297,7 @@ protected function createTwoOrderState() protected function createTwoTables() { - // Only create our own payment tracking table - no modifications to core PrestaShop tables + // Only create our own module tables - no modifications to core PrestaShop tables $sql = array(); $sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'twopayment` ( @@ -281,6 +317,35 @@ protected function createTwoTables() `two_invoice_uploaded_at` DATETIME NULL, PRIMARY KEY (`id_two`) ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; + + $sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'twopayment_attempt` ( + `id_attempt` int(11) NOT NULL AUTO_INCREMENT, + `attempt_token` VARCHAR(80) NOT NULL, + `id_cart` INT(11) UNSIGNED NOT NULL, + `id_customer` INT(11) UNSIGNED NOT NULL, + `id_order` INT(11) UNSIGNED NULL, + `customer_secure_key` VARCHAR(64) NOT NULL, + `merchant_order_id` VARCHAR(80) NOT NULL, + `two_order_id` VARCHAR(255) NULL, + `two_order_reference` VARCHAR(255) NULL, + `two_order_state` VARCHAR(64) NULL, + `two_order_status` VARCHAR(64) NULL, + `two_day_on_invoice` VARCHAR(32) NULL, + `two_payment_term_type` VARCHAR(20) DEFAULT "STANDARD", + `two_invoice_url` TEXT NULL, + `two_invoice_id` VARCHAR(255) NULL, + `cart_snapshot_hash` VARCHAR(64) NULL, + `order_create_idempotency_key` VARCHAR(128) NULL, + `status` VARCHAR(32) NOT NULL DEFAULT "CREATED", + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id_attempt`), + UNIQUE KEY `uniq_attempt_token` (`attempt_token`), + KEY `idx_attempt_cart` (`id_cart`), + KEY `idx_attempt_order` (`id_order`), + KEY `idx_attempt_two_order_id` (`two_order_id`), + KEY `idx_attempt_updated_at` (`updated_at`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; // Note: invoice_details (payment info) is NOT stored in DB - fetched from Two API when needed // This ensures payment details are always current and avoids stale data issues @@ -298,6 +363,7 @@ public function uninstall() $this->unregisterHook('actionAdminControllerSetMedia') && $this->unregisterHook('actionFrontControllerSetMedia') && $this->unregisterHook('actionOrderStatusUpdate') && + $this->unregisterHook('actionObjectOrderHistoryAddBefore') && $this->unregisterHook('paymentOptions') && $this->unregisterHook('displayPaymentReturn') && $this->unregisterHook('displayAdminOrderLeft') && @@ -325,8 +391,8 @@ protected function uninstallTwoSettings() Configuration::deleteByName('PS_TWO_ENABLE_COMPANY_ID'); Configuration::deleteByName('PS_TWO_ENABLE_DEPARTMENT'); Configuration::deleteByName('PS_TWO_ENABLE_PROJECT'); + Configuration::deleteByName('PS_TWO_ENABLE_TAX_SUBTOTALS'); Configuration::deleteByName('PS_TWO_FINALIZE_PURCHASE'); - Configuration::deleteByName('PS_TWO_ENABLE_ORDER_INTENT'); Configuration::deleteByName('PS_TWO_USE_ACCOUNT_TYPE'); Configuration::deleteByName('PS_TWO_DEBUG_MODE'); return true; @@ -630,7 +696,6 @@ protected function saveTwoGeneralFormValues() Configuration::updateValue('PS_TWO_MERCHANT_SHORT_NAME', $shortNameToSave); Configuration::updateValue('PS_TWO_MERCHANT_API_KEY', trim(Tools::getValue('PS_TWO_MERCHANT_API_KEY'))); Configuration::updateValue('PS_TWO_ENVIRONMENT', Tools::getValue('PS_TWO_ENVIRONMENT')); - Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', (int)Tools::getValue('PS_TWO_DISABLE_SSL_VERIFY', 0)); if ($this->verifiedMerchantId) { Configuration::updateValue('PS_TWO_MERCHANT_ID', $this->verifiedMerchantId); Configuration::updateValue('PS_TWO_API_KEY_VERIFIED', 1); @@ -844,19 +909,19 @@ protected function getTwoOtherForm() ), array( 'type' => 'switch', - 'label' => $this->l('Pre-approve the buyer during checkout and disable two if the buyer is declined'), - 'name' => 'PS_TWO_ENABLE_ORDER_INTENT', + 'label' => $this->l('Send tax subtotals in request payloads'), + 'name' => 'PS_TWO_ENABLE_TAX_SUBTOTALS', 'is_bool' => true, - 'desc' => $this->l('If you choose YES then pre-approve the buyer during checkout and disable two if the buyer is declined.'), + 'desc' => $this->l('If you choose YES, tax_subtotals will be sent in /v1/order and /v1/order_intent payloads. If you choose NO, tax_subtotals will be omitted from those payloads.'), 'required' => true, 'values' => array( array( - 'id' => 'PS_TWO_ENABLE_ORDER_INTENT_ON', + 'id' => 'PS_TWO_ENABLE_TAX_SUBTOTALS_ON', 'value' => 1, 'label' => $this->l('Yes') ), array( - 'id' => 'PS_TWO_ENABLE_ORDER_INTENT_OFF', + 'id' => 'PS_TWO_ENABLE_TAX_SUBTOTALS_OFF', 'value' => 0, 'label' => $this->l('No') ), @@ -921,8 +986,8 @@ protected function getTwoOtherFormValues() $fields_values['PS_TWO_ENABLE_PROJECT'] = Tools::getValue('PS_TWO_ENABLE_PROJECT', Configuration::get('PS_TWO_ENABLE_PROJECT')); $fields_values['PS_TWO_FINALIZE_PURCHASE'] = Tools::getValue('PS_TWO_FINALIZE_PURCHASE', Configuration::get('PS_TWO_FINALIZE_PURCHASE')); $fields_values['PS_TWO_USE_OWN_INVOICES'] = Tools::getValue('PS_TWO_USE_OWN_INVOICES', Configuration::get('PS_TWO_USE_OWN_INVOICES')); - $fields_values['PS_TWO_ENABLE_ORDER_INTENT'] = Tools::getValue('PS_TWO_ENABLE_ORDER_INTENT', Configuration::get('PS_TWO_ENABLE_ORDER_INTENT')); $fields_values['PS_TWO_ENABLE_B2B_B2C'] = Tools::getValue('PS_TWO_ENABLE_B2B_B2C', Configuration::get('PS_TWO_ENABLE_B2B_B2C')); + $fields_values['PS_TWO_ENABLE_TAX_SUBTOTALS'] = Tools::getValue('PS_TWO_ENABLE_TAX_SUBTOTALS', Configuration::get('PS_TWO_ENABLE_TAX_SUBTOTALS', 1)); $fields_values['PS_TWO_DISABLE_SSL_VERIFY'] = Tools::getValue('PS_TWO_DISABLE_SSL_VERIFY', Configuration::get('PS_TWO_DISABLE_SSL_VERIFY')); $fields_values['PS_TWO_DEBUG_MODE'] = Tools::getValue('PS_TWO_DEBUG_MODE', Configuration::get('PS_TWO_DEBUG_MODE')); return $fields_values; @@ -942,8 +1007,9 @@ protected function saveTwoOtherFormValues() Configuration::updateValue('PS_TWO_ENABLE_PROJECT', Tools::getValue('PS_TWO_ENABLE_PROJECT')); Configuration::updateValue('PS_TWO_FINALIZE_PURCHASE', Tools::getValue('PS_TWO_FINALIZE_PURCHASE')); Configuration::updateValue('PS_TWO_USE_OWN_INVOICES', Tools::getValue('PS_TWO_USE_OWN_INVOICES')); - Configuration::updateValue('PS_TWO_ENABLE_ORDER_INTENT', Tools::getValue('PS_TWO_ENABLE_ORDER_INTENT')); Configuration::updateValue('PS_TWO_ENABLE_B2B_B2C', Tools::getValue('PS_TWO_ENABLE_B2B_B2C')); + Configuration::updateValue('PS_TWO_ENABLE_TAX_SUBTOTALS', (int) Tools::getValue('PS_TWO_ENABLE_TAX_SUBTOTALS', 1)); + Configuration::updateValue('PS_TWO_DISABLE_SSL_VERIFY', (int) Tools::getValue('PS_TWO_DISABLE_SSL_VERIFY', 0)); Configuration::updateValue('PS_TWO_DEBUG_MODE', Tools::getValue('PS_TWO_DEBUG_MODE')); $this->output .= $this->displayConfirmation($this->l('Other settings are updated.')); @@ -988,6 +1054,7 @@ protected function renderTwoPluginInfo() ' . $this->l('Two is a B2B Buy Now, Pay Later solution') . '
' . $this->l('This plugin enables business customers to pay on invoice with instant credit decisions.') . ' + ' . $this->renderTwoPluginHealthChecklist() . '

' . $this->l('What the plugin CAN do') . '