diff --git a/.ai/decisions.md b/.ai/decisions.md new file mode 100644 index 0000000..d046a87 --- /dev/null +++ b/.ai/decisions.md @@ -0,0 +1,149 @@ +# Architectural Decisions + +> **Self-updating file** - AI agents should log significant decisions here. +> Add entries at the TOP (newest first). + +--- + +## How to Add Entries + +```markdown +## [YYYY-MM-DD] Decision Title + +**Context**: What problem or requirement triggered this? +**Decision**: What was decided? +**Alternatives Considered**: What else was considered? +**Rationale**: Why this approach? +**Consequences**: Trade-offs and implications +``` + +--- + +## [2026-01-22] Consolidate AI Context into CLAUDE.md + +**Context**: Had both `.cursor/rules/prestashop.mdc` and `CLAUDE.md` with overlapping content. + +**Decision**: Make CLAUDE.md the single source of truth with self-improvement protocols. The Cursor rules file can be removed or minimized. + +**Alternatives Considered**: +- Keep both files in sync (maintenance burden) +- Keep Cursor rules as primary (not Claude-optimized) + +**Rationale**: +- CLAUDE.md is auto-read by Claude +- Self-improvement instructions enable continuous enhancement +- Single source of truth prevents drift + +**Consequences**: +- Non-Claude AI models in Cursor won't get detailed rules (acceptable) +- Must keep CLAUDE.md updated (AI can self-update) + +--- + +## [2025-11-21] End-of-Month Payment Terms + +**Context**: B2B customers requested payment terms aligned with accounting cycles. + +**Decision**: Add `duration_days_calculated_from: "END_OF_MONTH"` API field with STANDARD/EOM type selection. + +**Alternatives Considered**: +- Hardcode EOM calculation client-side (rejected: Two backend should own this) +- Only support standard terms (rejected: customer demand) + +**Rationale**: +- EOM common in B2B invoicing +- Backward compatible via STANDARD default +- Clean UI separation + +**Consequences**: +- New database column needed +- EOM limited to 30/45/60 days (API constraint) +- Requires Two backend support + +--- + +## [2025-11-14] Separate Invoice Upload Service + +**Context**: Invoice upload logic was getting complex with signed URLs and polling. + +**Decision**: Create dedicated `TwoInvoiceUploadService.php` class. + +**Alternatives Considered**: +- Keep in main module file (rejected: 4000+ lines already) +- Use PrestaShop service container (rejected: version compatibility) + +**Rationale**: +- Single responsibility principle +- Easier testing and maintenance +- Clear interface + +**Consequences**: +- Additional file to maintain +- Must be included/autoloaded properly + +--- + +## [2025-10-06] Triple-Layer jQuery Fallback + +**Context**: jQuery not reliably available on PrestaShop 1.7.6.x with various themes. + +**Decision**: Implement three fallback layers plus JavaScript-side waiting. + +**Alternatives Considered**: +- Require jQuery in theme (rejected: can't control merchant themes) +- Use vanilla JS only (rejected: massive rewrite, PrestaShop uses jQuery) +- Single CDN fallback (rejected: may still race) + +**Rationale**: +- Belt-and-suspenders approach +- Guaranteed to work regardless of theme +- Minimal overhead + +**Consequences**: +- Slight initialization delay in worst case +- Extra code complexity +- Pattern must be followed in all JS + +--- + +## [2025-09-26] Server-Side Order Intent Verification + +**Context**: Client-side Order Intent check could be bypassed. + +**Decision**: Re-verify Order Intent server-side before creating order. + +**Alternatives Considered**: +- Trust client-side only (rejected: security risk) +- Skip client-side check (rejected: poor UX) + +**Rationale**: +- Defense in depth +- Payment security is non-negotiable +- Two API is idempotent + +**Consequences**: +- Extra API call at order creation +- Cached result prevents duplicate calls +- Guaranteed no order without valid intent + +--- + +## [2025-09-26] Modular JavaScript Architecture + +**Context**: Needed maintainable checkout JavaScript across PrestaShop versions. + +**Decision**: Split into focused modules: Manager, OrderIntent, CompanySearch, FieldValidation. + +**Alternatives Considered**: +- Single monolithic file (rejected: hard to maintain) +- ES6 modules with bundler (rejected: version compatibility) + +**Rationale**: +- Separation of concerns +- Individual component testing +- Clear responsibilities + +**Consequences**: +- Multiple script files need load order management +- Inter-module communication via manager +- Priority-based registration required diff --git a/.ai/learnings.md b/.ai/learnings.md new file mode 100644 index 0000000..d8daf5b --- /dev/null +++ b/.ai/learnings.md @@ -0,0 +1,113 @@ +# Learnings & Bug Fixes + +> **Self-updating file** - AI agents should append new learnings when fixing bugs. +> Add entries at the TOP of each section (newest first). + +--- + +## How to Add Entries + +```markdown +### [YYYY-MM-DD] Brief Title +**Problem**: What went wrong +**Root Cause**: Why it happened +**Fix**: How it was solved +**Files**: Which files were changed +``` + +--- + +## Shipping & Tax Calculation Issues + +### [2026-01-22] Shipping Missing When Free Shipping Cart Rule Active +**Problem**: Order intent `gross_amount` was €29 less than checkout total; shipping line item not included +**Root Cause**: Code used `$cart->getOrderTotal(true, Cart::ONLY_SHIPPING)` which returns **0** when a "Free shipping" cart rule is active (known PrestaShop behavior) +**Fix**: Use `$cart->getPackageShippingCost($cart->id_carrier, true)` to get carrier's actual cost BEFORE free shipping rules are applied. This method returns the carrier's configured price regardless of cart rules. +**Files**: `twopayment.php` → `getTwoProductItems()` shipping detection + +### [2026-01-22] Tax Rate Shows 20% Instead of 21% (Rounding Errors) +**Problem**: Tax rate displayed as 0.200000 instead of 0.210000; calculated from amounts instead of configured rate +**Root Cause**: Code was calculating tax rate from `(gross - net) / net` which can have rounding errors +**Fix**: Use PrestaShop's native `rate` field from cart products as primary source (it's the configured tax percentage). Only fall back to calculated rate when native field is unavailable or 0 but tax was actually charged. +**Files**: `twopayment.php` → `getTwoProductItems()` tax rate calculation + +### [2026-01-22] Shipping Tax Rate Not Using Carrier Configuration +**Problem**: Shipping tax rate was derived from amounts, not from carrier's configured tax rules group +**Fix**: Use `$carrier->getIdTaxRulesGroup()` + `TaxManagerFactory::getManager()` + `getTaxCalculator()` to get the actual configured shipping tax rate +**Files**: `twopayment.php` → `getTwoProductItems()` shipping tax calculation + +### [2026-01-22] Store Tax Rules Not Applied (0% Tax on Products) +**Problem**: Products showed 0% tax despite "ES Standard rate (21%)" tax rule being assigned +**Root Cause**: **Store misconfiguration** - Tax rule existed but wasn't configured to apply to Spain (ES) country. This is a PrestaShop admin configuration issue, NOT a module bug. +**Fix**: Store admin needed to edit the tax rule and add Spain to the country list +**Files**: N/A - Store configuration issue + +--- + +## jQuery & JavaScript Issues + +### [2025-10-06] jQuery Race Condition on PS 1.7.6.x +**Problem**: `$ is not defined` errors on checkout +**Root Cause**: Theme-dependent jQuery loading - scripts execute before jQuery loads +**Fix**: Triple-layer PHP fallback + `waitForJQuery()` JS wrapper +**Files**: `twopayment.php`, all JS modules + +--- + +## API Integration Issues + +### [2025-11-21] Tax Rate 0% Despite Tax Applied +**Problem**: Two API rejects with tax formula validation error +**Root Cause**: PrestaShop `rate` field can be 0 even when tax exists in gross/net +**Fix**: Calculate rate from `(gross - net) / net` as source of truth +**Files**: `twopayment.php` → `buildOrderPayload()` + +### [2025-11-21] Phone Validation Failures +**Problem**: "Invalid phone number" from Two API +**Root Cause**: Customer used `phone_mobile` field, `phone` was empty +**Fix**: Fallback chain: `phone` → `phone_mobile` +**Files**: `twopayment.php` → buyer payload building + +### [2025-11-14] Gross Amount Mismatch (1 cent off) +**Problem**: "Total invoice amount doesn't match" errors +**Root Cause**: PHP floating point vs PrestaShop's stored rounded values +**Fix**: Use `Tools::ps_round()` and 2-cent tolerance constant +**Files**: `twopayment.php` → line item calculations + +--- + +## Checkout Flow Issues + +### [2025-10-06] Order Intent Fires Multiple Times +**Problem**: API rate limiting, duplicate processing +**Root Cause**: `updatedPaymentForm` event fires on every checkout change +**Fix**: 800ms cooldown + result caching + `isProcessing` guard +**Files**: `TwoCheckoutManager.js`, `TwoOrderIntent.js` + +### [2025-10-06] Payment Option Not Found +**Problem**: Two payment option not detected in custom themes +**Root Cause**: Themes customize payment markup differently +**Fix**: Multiple selector strategies (data-attr → value → action → text) +**Files**: `TwoCheckoutManager.js` → `findTwoPaymentOption()` + +--- + +## Configuration Issues + +### [2025-11-14] API Key Typo Migration +**Problem**: Old installations had `PS_TWO_MERACHANT_API_KEY` (typo) +**Root Cause**: Original code had typo in config key +**Fix**: Upgrade script migrates to `PS_TWO_MERCHANT_API_KEY` +**Files**: `upgrade/upgrade-2.2.0.php` + +--- + +## Template for New Entries + +```markdown +### [YYYY-MM-DD] Title +**Problem**: +**Root Cause**: +**Fix**: +**Files**: +``` diff --git a/.cursor/rules/prestashop.mdc b/.cursor/rules/prestashop.mdc new file mode 100644 index 0000000..1b71e80 --- /dev/null +++ b/.cursor/rules/prestashop.mdc @@ -0,0 +1,49 @@ +--- +alwaysApply: true +--- +# Two Payment Module - AI Development Rules + +> **Primary context source: `CLAUDE.md` in repository root** +> This file exists for non-Claude AI models. All detailed rules are in CLAUDE.md. + +## Quick Rules (see CLAUDE.md for full details) + +1. **Read CLAUDE.md first** - Contains all development patterns and rules +2. **Payment safety** - Never deploy untested, always try-catch, never log secrets +3. **Cross-version** - Test on PS 1.7.6+, 8.x, and 9.x +4. **jQuery fallback** - Triple-layer loading + waitForJQuery wrapper +5. **Assets** - Only load on checkout pages, never async +6. **Self-improve** - Update CLAUDE.md and .ai/*.md files when learning + +## File References + +| Purpose | File | +|---------|------| +| Full AI context | `CLAUDE.md` | +| Architectural decisions | `.ai/decisions.md` | +| Bug fixes & learnings | `.ai/learnings.md` | +| Module documentation | `README.md` | +| Version history | `CHANGELOG.md` | + +## Critical Patterns + +```php +// Always wrap hooks +public function hookAnyHook($params) { + try { + // logic + } catch (Exception $e) { + PrestaShopLogger::addLog('TwoPayment: ' . $e->getMessage(), 3); + return; + } +} +``` + +```javascript +// Always wrap JS init +waitForJQuery(function() { + // Safe to use jQuery +}); +``` + +**For complete rules, patterns, and self-improvement protocols, see `CLAUDE.md`.** 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/.gitignore b/.gitignore index 8137ce9..136ca92 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,12 @@ TEST*.xml .DS_Store __MACOSX +# IDE - Cursor (optional: can track .cursor/rules/ if sharing rules with team) +# .cursor/ + +# AI context files - TRACK THESE (valuable for AI and developers) +# .ai/ is tracked intentionally + # Generated by Windows Thumbs.db 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 225ea82..c18b566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,336 @@ 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 +- **Invoice Upload Feature**: Re-enabled the invoice upload functionality + - When enabled, PrestaShop-generated PDF invoices are uploaded to Two when orders are fulfilled + - Merchants can customize their invoice templates to include Two's payment details + - PrestaShop invoice templates can be modified in `/themes/[theme]/pdf/invoice.tpl` or `/pdf/invoice.tpl` + - Feature remains disabled by default - must be coordinated with Two before enabling + +### Changed +- **Invoice Upload Configuration**: Re-enabled `PS_TWO_USE_OWN_INVOICES` toggle in admin settings + - Clear instructions explaining merchants must customize their invoice template + - Example Smarty code provided for adding Two-specific content only to Two orders + - Shows how to use `{if $order->module == 'twopayment'}` conditional + - Warning to contact Two support before enabling + +### Technical +- No database schema changes required +- No new hooks required +- Invoice upload uses existing Three-step process (request URL, upload to cloud storage, poll status) + +--- + +## [2.3.1] - 2026-01-22 + +### Added +- **Plugin Information Tab**: New admin tab displaying plugin capabilities, limitations, and troubleshooting tips + - Clear list of what the plugin can and cannot do + - Important requirements for customers (company name, phone, etc.) + - Common troubleshooting tips with solutions + - Support contact information and version display + +### Fixed +- **Tax Amount Calculation**: Fixed "Line item tax amount differs from tax rate * net amount" API errors + - Tax amount now calculated using Two's required formula: `tax_amount = net_amount * tax_rate` + - Ensures mathematical consistency between tax_rate, net_amount, and tax_amount + - Resolves API rejection for orders with rounding discrepancies +- **Shipping Cost with Free Shipping**: Fixed shipping detection when free shipping cart rules are active + - Now uses `getPackageShippingCost()` to get carrier cost before cart rules are applied + - Shipping line item now includes correct amount even with free shipping promotions +- **Tax Rate Sourcing**: Improved tax rate determination using PrestaShop's native `rate` field + - Primary source: PrestaShop's configured tax rate (canonical value) + - Fallback: Calculate from amounts when rate field is unavailable + - Edge case handling for tax-exempt customers and rate field inconsistencies + +### Changed +- **Tax Calculation Logic**: Tax amounts are now calculated from rates instead of taken from PrestaShop + - Guarantees Two API formula compliance: `tax_amount = net_amount * tax_rate` + - Gross amount validation with configurable tolerance + - Debug logging for rate variances when debug mode is enabled + +### Technical +- No database schema changes required +- Backward compatible with all existing configurations +- PHP 7.1+ compatible + +## [2.3.0] - 2025-11-21 + +### Added +- **End-of-Month (EOM) Payment Terms**: New payment term type for B2B invoicing + - Supports EOM+30, EOM+45, and EOM+60 day terms + - Payment calculated from end of current month at fulfillment, plus selected days + - Example: Order fulfilled Jan 15 with EOM+30 = Payment due Feb 28 (end of Jan + 30 days) +- **Payment Term Type Configuration**: Radio button selection in admin (Standard vs EOM) + - Dynamic UI: EOM mode shows only 30/45/60 day options + - Standard mode shows all available terms (7/15/20/30/45/60/90 days) + - Clear explanations with real-world examples for each type +- **API Integration**: `duration_days_calculated_from: "END_OF_MONTH"` field added to order payload for EOM terms +- **Database Schema**: Added `two_payment_term_type` column to store term type per order +- **Enhanced Buyer Display**: + - Standard terms: "Pay in 30 days" (multilingual) + - EOM terms: "Pay in 30 days from end of month" (clear, localized) + - Dynamic description text changes based on term type +- **Admin Order View**: Shows "End of Month + 30 days" with EOM badge for clarity +- **Upgrade Script**: `upgrade-2.3.0.php` with backward-compatible defaults +- **Debug Mode**: Admin toggle for detailed diagnostic logging (Other Settings → Enable Debug Mode) + - Logs tax calculations, rate fields, and gross/net amounts per product + - Only enable when requested by Two support for troubleshooting +- **Phone Number Fallback**: Automatic fallback from `phone` to `phone_mobile` field + - Handles cases where customers only provide mobile number + - Graceful handling when no phone provided (Two API validates) + +### Changed +- **Checkout Display**: Smart label/unit hiding for EOM terms (no verbose "Pay in EOM+30 days") +- **Payment Terms Selector**: Term format changes based on type (tooltips explain EOM) +- **API Payload Builder**: New `buildTermsPayload()` method conditionally adds EOM field +- **Available Terms Logic**: `getAvailablePaymentTerms()` filters based on term type +- **Tax Rate Calculation**: Now validates tax rate from actual amounts (gross - net / net) + - Handles edge case where PrestaShop `rate` field is 0 but tax is applied + - Logs anomalies when rate field doesn't match calculated rate + - Uses calculated rate as source of truth (what customer actually pays) +- **Company Messaging**: Clearer guidance when company data is missing + - "Go back to your billing address and enter your company name in the Company field" + - "Go back to your billing address and search for your company name. Select your company from the results" + - Specific status codes: `no_company`, `incomplete_company` for better UX + +### Fixed +- Invoice upload feature temporarily disabled (will be re-enabled after further testing) +- **Tax Rate 0% Issue**: Fixed edge case where tax rate was sent as 0 despite tax being applied + - Now calculates rate from PrestaShop's actual gross/net amounts +- **Phone Validation Errors**: User-friendly messages for invalid phone numbers + - "The phone number in your billing address appears to be invalid. Please go back and ensure you have entered a valid phone number for your country." +- **API Validation Errors**: Comprehensive parsing of Two API validation errors + - Phone, email, address, and company validation errors now show user-friendly messages + - Generic fallback for unknown validation errors + +### Technical +- **PHP 7.1+ Compatible**: No spread operators, arrow functions, or typed properties +- **Backward Compatible**: Existing merchants default to STANDARD type +- **Historical Orders**: Upgrade script marks existing orders as STANDARD +- **ES5 JavaScript**: Uses `function()` syntax instead of arrow functions in loops +- **Security**: Whitelist validation for term type (only STANDARD or EOM accepted) + +### User Experience +- Clear admin explanations with fulfillment date examples +- Language-friendly checkout display (works in ES, EN, DE, FR, etc.) +- Tooltips on EOM term options +- EOM badge in admin order view with explanation +- Concise term display without verbose text + ## [2.2.0] - 2025-11-14 ### Added @@ -91,6 +421,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Upgrade Notes +### Upgrading to 2.3.0 + +1. **Backup**: Always backup your database before upgrading +2. **Automatic Migration**: Upgrade script automatically: + - Adds `two_payment_term_type` column (VARCHAR(20), default 'STANDARD') + - Sets `PS_TWO_PAYMENT_TERM_TYPE` configuration to 'STANDARD' + - Updates existing orders to STANDARD type (no visible change) +3. **Backward Compatible**: Existing merchants see no changes + - Payment terms continue to work exactly as before + - All existing orders display correctly as standard terms +4. **New Feature**: EOM payment terms available as opt-in + - Configure in module admin: Payment Term Type radio button + - Only affects new orders after enabling EOM +5. **API Compatibility**: EOM requires Two backend support + - Test on staging environment first + - Verify `duration_days_calculated_from` field is accepted +6. **No Breaking Changes**: Standard terms unchanged, EOM is additive + ### Upgrading to 2.2.0 1. **Backup**: Always backup your database before upgrading @@ -120,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/README.md b/README.md index 1b6d95f..3308196 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ Two is a B2B payment method that lets your business customers pay by invoice wit - **Organization Number Capture**: Hidden field (`companyid`) automatically populated from company selection - **Order Intent Check**: Frontend validation before payment confirmation - **Server-Side Verification**: Defense-in-depth security with server-side Order Intent verification -- **Payment Terms UI**: Configurable payment terms (7/15/20/30/45/60/90 days) with user selection +- **Payment Terms UI**: Configurable payment terms with user selection + - **Standard Terms**: 7/15/20/30/45/60/90 days from fulfillment date + - **End-of-Month (EOM) Terms**: 30/45/60 days from end of current month at fulfillment - **Admin Integration**: Two order ID, state, status, and invoice URL displayed in order pages - **Invoice Upload**: Automatic upload of PrestaShop-generated invoices to Two (optional feature) @@ -26,8 +28,14 @@ Two is a B2B payment method that lets your business customers pay by invoice wit - Cross-version compatibility (PrestaShop 1.7.6 - 9.x) - Theme-agnostic implementation - jQuery compatibility handling for older PrestaShop versions -- Comprehensive error logging +- Comprehensive error logging with optional debug mode - Order payload validation ensuring exact PrestaShop invoice matching +- 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 @@ -54,8 +62,11 @@ Two is a B2B payment method that lets your business customers pay by invoice wit 3. **API Key**: Enter your Two API key for the selected environment - The module validates the API key on save - Invalid keys will show an error message -4. **Payment Terms**: Configure available payment terms - - Enable/disable individual terms: 7, 15, 20, 30, 45, 60, 90 days +4. **Payment Terms**: Configure payment term type and available terms + - **Term Type**: Choose Standard or End-of-Month (EOM) terms + - **Standard**: Payment due X days from fulfillment date (all durations available) + - **EOM**: Payment due at end of current month + X days (30/45/60 only) + - Enable/disable individual terms based on selected type - Set default payment term (defaults to 30 days if available) 5. **Optional Features**: - Enable/disable company name field requirement @@ -73,7 +84,8 @@ Two is a B2B payment method that lets your business customers pay by invoice wit |--------|-------------|---------| | Environment | Sandbox or Production | Sandbox | | API Key | Two merchant API key | Required | -| Payment Terms | Available terms (7-90 days) | 30 days enabled | +| Payment Term Type | Standard or End-of-Month (EOM) | Standard | +| Payment Terms | Available terms based on type | 30 days enabled | | Default Payment Term | Default term when multiple available | 30 days | | Company Name | Require company name field | Enabled | | Organization Number | Require organization number | Enabled | @@ -84,6 +96,55 @@ Two is a B2B payment method that lets your business customers pay by invoice wit | Auto Fulfill Orders | Automatically fulfill orders with Two when status changes | Enabled | | Invoice Upload | Auto-upload invoices to Two | Disabled | | SSL Verification | Verify SSL certificates | Enabled | +| Debug Mode | Enable detailed diagnostic logging | Disabled | + +## Payment Terms: Standard vs End-of-Month (EOM) + +The module supports two types of payment terms to match your B2B invoicing practices: + +### Standard Payment Terms + +Payment is due **X days from the fulfillment date**. + +**Example:** +- Order fulfilled: January 15 +- Payment term: 30 days +- **Payment due: February 14** (Jan 15 + 30 days) + +**Available durations:** 7, 15, 20, 30, 45, 60, 90 days + +**When to use:** +- Simple, straightforward payment terms +- Common for B2B transactions +- Easy for buyers to understand + +### End-of-Month (EOM) Payment Terms + +Payment is due at the **end of the current month (at fulfillment) plus X days**. + +**Example:** +- Order fulfilled: January 15 +- Payment term: EOM+30 +- Calculation: End of January (Jan 31) + 30 days +- **Payment due: February 28** (or Feb 29 in leap years) + +**Available durations:** 30, 45, 60 days only + +**When to use:** +- Aligns with monthly accounting cycles +- Common in industries with monthly billing +- Simplifies payment tracking for buyers with multiple orders + +**Display:** +- Admin: "End of Month + 30 days" +- Checkout: "Pay in 30 days from end of month" + +**How it works:** +1. Two's backend calculates the end of the month when the order is fulfilled +2. Adds the specified days to that date +3. Buyer receives invoice with the calculated due date + +--- ## How It Works @@ -113,10 +174,10 @@ Two is a B2B payment method that lets your business customers pay by invoice wit #### 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 @@ -267,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 @@ -331,6 +421,45 @@ The module builds order payloads that exactly match PrestaShop invoices: - Check Order Intent was approved - Verify JavaScript loaded correctly - Check browser console for errors + - Ensure company is selected (not just typed) - search and click a result + +### "Invalid Phone Number" Error +- **Symptom**: Order fails with phone validation error +- **Solutions**: + - Ensure customer has entered a valid phone number in billing address + - Module tries both `phone` and `phone_mobile` fields automatically + - Phone must be valid for the selected country + - Check billing address has a phone number filled in + +### "Company Details Required" Message +- **Symptom**: Two payment shows message asking to provide company details +- **Solutions**: + - Customer must enter company name in the billing address Company field + - Customer must search and **select** their company from the dropdown results + - Simply typing a company name is not enough - must click to select from search + - If using an existing address, customer should edit it to add/verify company + +### Tax Rate Issues (0% Tax) +- **Symptom**: Two API rejects order with tax rate error +- **Solutions**: + - Enable Debug Mode in module settings (Other Settings → Enable Debug Mode) + - Check PrestaShop logs for "TwoPayment: Product tax debug" entries + - Verify products have correct tax rules assigned in PrestaShop + - Module now calculates tax from actual amounts as fallback + - Contact Two support with debug logs if issue persists + +### Debug Mode +- **When to use**: Only enable when requested by Two support for troubleshooting +- **What it logs**: + - Tax calculations per product (rate field, net/gross amounts, calculated rate) + - Helps diagnose tax rate discrepancies between PrestaShop and Two API +- **How to enable**: + 1. Go to Module Configuration → Other Settings + 2. Toggle "Enable Debug Mode" to Yes + 3. Save settings + 4. Reproduce the issue + 5. Check PrestaShop logs (`var/logs/`) + 6. Disable Debug Mode when done ## Security @@ -379,4 +508,4 @@ Two Commercial License ## Copyright -© 2021-2025 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 1d339fe..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 976a55e..ba5758a 100644 --- a/controllers/front/confirmation.php +++ b/controllers/front/confirmation.php @@ -18,60 +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_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 607a927..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)) { + // Backward compatibility: older clients may still send delivery id only. + $addressId = (int)Tools::getValue('id_address_delivery'); + } if (empty($addressId)) { - // Fallback to delivery address from cart, then invoice address - $addressId = $cart->id_address_delivery ?: $cart->id_address_invoice; + // 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,29 +242,38 @@ 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); + // ENHANCED VALIDATION: Provide clear status codes for different company data scenarios + // This allows frontend to show specific guidance to users - // Simple validation - require both company name and organization number + // Case 1: No company name at all - user hasn't entered company details if (empty($companyName)) { - PrestaShopLogger::addLog('TwoPayment: ERROR - No company name provided in form data', 3); + PrestaShopLogger::addLog('TwoPayment: No company name provided - prompting user', 2); $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Company name is required for business accounts' + 'status' => 'no_company', + 'error' => $this->module->l('To pay with Two, go back to your billing address and enter your company name in the Company field.') ])); return; } + // Case 2: Has company name but no org number - common with existing addresses if (empty($companyId)) { - PrestaShopLogger::addLog('TwoPayment: ERROR - No organization number provided in form data', 3); + PrestaShopLogger::addLog('TwoPayment: Company name exists but no org number - prompting user to search', 2); $this->sendJsonResponse(json_encode([ 'success' => false, - 'error' => 'Organization number is required. Please select your company from the search results.' + 'status' => 'incomplete_company', + 'error' => $this->module->l('To pay with Two, go back to your billing address and search for your company name. Select your company from the results to verify your business.') ])); return; } @@ -260,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; } @@ -285,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; } @@ -301,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; } @@ -324,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, @@ -337,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() @@ -345,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; } @@ -372,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; } @@ -445,12 +466,18 @@ public function sendJsonResponse($content) } /** - * Get company data using PrestaShop-native fallback chain - * Priority: Form data → Session → Address → Database + * Get company data using PrestaShop-native fallback chain with smart auto-resolution + * Priority: Form data → Session → Address fields (with org number verification via Two API) + * + * CRITICAL FIX: When a logged-in user uses an existing address, we check for organization + * numbers stored in address fields (dni, vat_number) and verify them via Two's API. + * This is MORE RELIABLE than searching by company name because org numbers give exact matches. + * + * Example: https://api.two.inc/companies/v2/company?q=A81304487&country=ES returns exact match */ private function getCompanyDataWithFallbacks() { - // Priority 1: Form data (highest priority - direct user input) + // Priority 1: Form data (highest priority - direct user input from company search) $company = trim(Tools::getValue('company', '')); $companyId = trim(Tools::getValue('companyid', '')); @@ -459,28 +486,137 @@ private function getCompanyDataWithFallbacks() return ['company' => $company, 'companyid' => $companyId]; } - // Priority 2: PrestaShop session/cookie (persisted from previous steps) - if (isset($this->context->cookie->two_company_name) && !empty($this->context->cookie->two_company_name)) { - PrestaShopLogger::addLog('TwoPayment: Company data retrieved from PrestaShop session', 1); + // 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) + // 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); return [ - 'company' => $this->context->cookie->two_company_name, - 'companyid' => $this->context->cookie->two_company_id ?? '' + 'company' => $sessionCompany, + 'companyid' => $sessionCompanyId ]; } - // Priority 3: Customer's current address data - if ($this->context->customer->isLogged()) { - $address = new Address($this->context->cart->id_address_invoice); - if (Validate::isLoadedObject($address) && !empty($address->company)) { - PrestaShopLogger::addLog('TwoPayment: Company data retrieved from customer address', 1); - return [ - 'company' => $address->company, - 'companyid' => $this->getStoredCompanyId($address->company) ?? '' - ]; + // 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 + $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 (!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']; + + // 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, + 1 + ); + + return [ + 'company' => trim($address->company), + 'companyid' => '' // User needs to search and select + ]; + } } } - // Priority 4: Check if we have any partial data + // Priority 4: Partial session data (company name without org number) + if (!empty($sessionCompany) && empty($sessionCompanyId)) { + PrestaShopLogger::addLog( + 'TwoPayment: Session has company name but no org number - user needs to search', + 1 + ); + return [ + 'company' => $sessionCompany, + 'companyid' => '' + ]; + } + + // Priority 5: Any partial form data if (!empty($company) || !empty($companyId)) { PrestaShopLogger::addLog('TwoPayment: Partial company data found - company: "' . $company . '", companyid: "' . $companyId . '"', 2); return ['company' => $company, 'companyid' => $companyId]; @@ -499,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 d9d10e2..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,12 +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_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 @@ -248,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 @@ -294,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 e394bbc..383f0cd 100644 --- a/translations/es.php +++ b/translations/es.php @@ -15,8 +15,17 @@ $_MODULE['<{twopayment}prestashop>twopayment_636cf8408eb393133d3495581642ecca'] = 'Selecciona el entorno de API de Two que deseas usar. Producción para transacciones reales, Desarrollo para pruebas.'; $_MODULE['<{twopayment}prestashop>twopayment_330f49df8243756a8a4dc7f7f7ee6dfe'] = 'Desarrollo'; $_MODULE['<{twopayment}prestashop>twopayment_756d97bb256b8580d4d71ee0c547804e'] = 'Producción'; +$_MODULE['<{twopayment}prestashop>twopayment_90732912a0dcac1e45f02ba8122d80bf'] = 'Tipo de plazo de pago'; +$_MODULE['<{twopayment}prestashop>twopayment_e8ca0cae6b365fe0c03816ef8905b89c'] = 'Elige cómo se calculan los términos de pago:'; +$_MODULE['<{twopayment}prestashop>twopayment_d7763ceb9a0400fe241b0d5e6f0b5b21'] = 'Términos estándar:'; +$_MODULE['<{twopayment}prestashop>twopayment_d801910356f73cbb407fe5d320c581d7'] = 'Pago vencido X días desde la fecha de cumplimiento. Ejemplo: Si cumples un pedido el 15 de enero con términos de 30 días, el pago vence el 14 de febrero.'; +$_MODULE['<{twopayment}prestashop>twopayment_526f52140844e831b1965500d17c26d8'] = 'Términos Fin de Mes (EOM):'; +$_MODULE['<{twopayment}prestashop>twopayment_539de410a9a131c0e925dc8e248a29d2'] = 'Pago vencido al final del mes actual más X días desde la fecha de cumplimiento. Ejemplo: Si cumples un pedido el 15 de enero con términos EOM+30, el pago vence el 28 de febrero (fin de enero + 30 días). Esto es común en facturación B2B.'; +$_MODULE['<{twopayment}prestashop>twopayment_3438dbc5b197844ab4cb01ab14af8a7b'] = 'Términos estándar (p. ej., 30 días desde el cumplimiento)'; +$_MODULE['<{twopayment}prestashop>twopayment_744d6eb67244c953ac8d7835d59581d9'] = 'Términos Fin de Mes (p. ej., EOM + 30 días)'; $_MODULE['<{twopayment}prestashop>twopayment_a39746aced7f2b7acba1b2f715654995'] = 'Plazos de pago disponibles'; -$_MODULE['<{twopayment}prestashop>twopayment_5ac60b12ff162aa4b9ae98482ee9fcad'] = 'Selecciona los plazos de pago que deseas ofrecer a tus clientes en el proceso de compra. Si solo eliges uno, se usará por defecto. Si eliges varios, se mostrará un selector.'; +$_MODULE['<{twopayment}prestashop>twopayment_34d4c5b4f293f3953b9f1cea4ada6a1d'] = 'Selecciona los términos de pago que deseas ofrecer. Los términos estándar se calculan desde la fecha de cumplimiento.'; +$_MODULE['<{twopayment}prestashop>twopayment_de68657d5c597fa395ed600a61c98178'] = 'Selecciona los términos de pago que deseas ofrecer. Los términos EOM (Fin de Mes) se calculan desde el final del mes en que se cumple el pedido, más los días seleccionados. Solo están disponibles términos de 30, 45 y 60 días para EOM.'; $_MODULE['<{twopayment}prestashop>twopayment_cb8f14fd3a41cfe1236a3c6b90077ca0'] = '7 días'; $_MODULE['<{twopayment}prestashop>twopayment_856411d88c93b1a1d5aac7e455511142'] = '15 días'; $_MODULE['<{twopayment}prestashop>twopayment_331be6e0a973ee9b536fa5357159e6c4'] = '20 días'; @@ -48,15 +57,92 @@ $_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)'; $_MODULE['<{twopayment}prestashop>twopayment_980d43b4b58ff388e0f1c3c5ff253d1d'] = 'ADVERTENCIA: Activa esto únicamente si estás detrás de un proxy corporativo con certificados SSL personalizados. Esto desactiva la verificación de certificados SSL y es un riesgo de seguridad. NO RECOMENDADO para producción.'; $_MODULE['<{twopayment}prestashop>twopayment_9f67feb76396d9f95843662cb1a3cbee'] = 'Sí (No recomendado)'; $_MODULE['<{twopayment}prestashop>twopayment_8233c6a9f4ea05ab8f1d02225e51ef84'] = 'No (Seguro)'; +$_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:'; @@ -90,16 +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_184aad6af6fa869bf7fa4f9b257c4bab'] = 'El pago con Two no está disponible para este pedido. Por favor, selecciona otro método de pago.'; +$_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_adb1bb2b152cea383d4831987c84e49f'] = 'Para pagar con Two, selecciona tu empresa de los resultados de búsqueda para que podamos verificar tu negocio y ofrecerte plazos de factura.'; +$_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'; @@ -113,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'; @@ -120,26 +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>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_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_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'; @@ -166,12 +323,13 @@ $_MODULE['<{twopayment}prestashop>paymentinfo_5c920817023991f458e2a248e9367c67'] = 'Verificación de aprobación instantánea'; $_MODULE['<{twopayment}prestashop>paymentinfo_26615a6d6e63f2904432b8b425ee6e93'] = 'Comprobando disponibilidad...'; $_MODULE['<{twopayment}prestashop>paymentinfo_c1cf258d03ea6e7a7d9a004d3a017610'] = 'Elige la opción de Compra Ahora, Paga Después que mejor se adapte a ti'; -$_MODULE['<{twopayment}prestashop>paymentinfo_8f69593285888e6c150560a3746ed4eb'] = 'Tu periodo de pago comienza cuando tu pedido se haya completado, junto con la factura de Two'; -$_MODULE['<{twopayment}prestashop>paymentinfo_b4fe334d1b7cdcbc01db8426803ebaff'] = 'Pagar en'; -$_MODULE['<{twopayment}prestashop>paymentinfo_44fdec47036f482b68b748f9d786801b'] = 'días'; +$_MODULE['<{twopayment}prestashop>paymentinfo_a002c8066738bc8f9d9394abdcef7ea8'] = 'Tu período de pago comienza cuando se cumple tu pedido'; +$_MODULE['<{twopayment}prestashop>paymentinfo_1ffcb4e0a351d5e143bec6362c0feaf8'] = 'El pago vence al final del mes actual más los días seleccionados desde que se cumple tu pedido'; $_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'; @@ -191,7 +349,12 @@ $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_674d9f62330449287efd1512e05fbb43'] = 'ID de pedido Two'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_1e860e56970a81a1ba3e1fcb7fccc846'] = 'Referencia del pedido'; $_MODULE['<{twopayment}prestashop>displayadminordertabcontent_3ec74eaa839c4e1851706ef6709dfbbb'] = 'Plazos de pago'; +$_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'; @@ -209,12 +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>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 eecb461..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.2.0'; + $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(); } /** @@ -97,6 +112,27 @@ 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() { if (Shop::isFeatureActive()) { @@ -107,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') && @@ -140,10 +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 @@ -259,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` ( @@ -270,6 +308,7 @@ protected function createTwoTables() `two_order_state` TEXT NULL, `two_order_status` TEXT NULL, `two_day_on_invoice` TEXT NULL, + `two_payment_term_type` VARCHAR(20) DEFAULT "STANDARD", `two_invoice_url` TEXT NULL, `two_invoice_id` VARCHAR(255) NULL, `two_invoice_upload_status` ENUM("PENDING", "UPLOADING", "UPLOADED", "FAILED", "NOT_APPLICABLE") DEFAULT "NOT_APPLICABLE", @@ -279,6 +318,37 @@ protected function createTwoTables() 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 + foreach ($sql as $query) { if (Db::getInstance()->execute($query) == false) { return false; @@ -293,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') && @@ -320,9 +391,10 @@ 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; } @@ -373,6 +445,7 @@ public function getContent() 'renderTwoGeneralForm' => $this->renderTwoGeneralForm(), 'renderTwoOtherForm' => $this->renderTwoOtherForm(), 'renderTwoOrderStatusForm' => $this->renderTwoOrderStatusForm(), + 'renderTwoPluginInfo' => $this->renderTwoPluginInfo(), 'twotabvalue' => Configuration::get('PS_TWO_TAB_VALUE'), 'two_api_verified' => (int) Configuration::get('PS_TWO_API_KEY_VERIFIED'), 'two_merchant_id' => Configuration::get('PS_TWO_MERCHANT_ID'), @@ -454,47 +527,73 @@ protected function getTwoGeneralForm() 'name' => 'name' ) ), + array( + 'type' => 'radio', + 'label' => $this->l('Payment Term Type'), + 'name' => 'PS_TWO_PAYMENT_TERM_TYPE', + 'desc' => $this->l('Choose how payment terms are calculated:') . '

' . $this->l('Standard Terms:') . ' ' . $this->l('Payment due X days from fulfillment date. Example: If you fulfill an order on January 15th with 30-day terms, payment is due February 14th.') . '

' . $this->l('End-of-Month (EOM) Terms:') . ' ' . $this->l('Payment due at the end of the current month plus X days from fulfillment date. Example: If you fulfill an order on January 15th with EOM+30 terms, payment is due February 28th (end of January + 30 days). This is common for B2B invoicing.'), + 'is_bool' => false, + 'values' => array( + array( + 'id' => 'term_type_standard', + 'value' => 'STANDARD', + 'label' => $this->l('Standard Terms (e.g., 30 days from fulfillment)') + ), + array( + 'id' => 'term_type_eom', + 'value' => 'EOM', + 'label' => $this->l('End-of-Month Terms (e.g., EOM + 30 days)') + ), + ), + ), array( 'type' => 'checkbox', 'label' => $this->l('Available Payment Terms'), 'name' => 'PS_TWO_PAYMENT_TERMS', - 'desc' => $this->l('Select which payment terms you want to offer to your customers at checkout. If only one term is selected, it will be used as the default. Multiple terms will show a selector.'), + 'desc' => '', 'values' => array( 'query' => array( array( 'id' => '7', 'name' => $this->l('7 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-7 two-term-standard' ), array( 'id' => '15', 'name' => $this->l('15 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-15 two-term-standard' ), array( 'id' => '20', 'name' => $this->l('20 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-20 two-term-standard' ), array( 'id' => '30', 'name' => $this->l('30 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-30 two-term-both' ), array( 'id' => '45', 'name' => $this->l('45 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-45 two-term-both' ), array( 'id' => '60', 'name' => $this->l('60 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-60 two-term-both' ), array( 'id' => '90', 'name' => $this->l('90 days'), - 'val' => '1' + 'val' => '1', + 'class' => 'two-term-option two-term-90 two-term-standard' ), ), 'id' => 'id', @@ -521,6 +620,9 @@ protected function getTwoGeneralFormValues() $fields_values['PS_TWO_MERCHANT_API_KEY'] = Tools::getValue('PS_TWO_MERCHANT_API_KEY', Configuration::get('PS_TWO_MERCHANT_API_KEY')); $fields_values['PS_TWO_ENVIRONMENT'] = Tools::getValue('PS_TWO_ENVIRONMENT', Configuration::get('PS_TWO_ENVIRONMENT')); + // Payment term type (STANDARD or EOM) + $fields_values['PS_TWO_PAYMENT_TERM_TYPE'] = Tools::getValue('PS_TWO_PAYMENT_TERM_TYPE', Configuration::get('PS_TWO_PAYMENT_TERM_TYPE')); + // Payment terms checkboxes $payment_terms = array_map('strval', self::PAYMENT_TERMS_OPTIONS); foreach ($payment_terms as $term) { @@ -594,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); @@ -603,6 +704,12 @@ protected function saveTwoGeneralFormValues() Configuration::updateValue('PS_TWO_API_KEY_VERIFIED', 0); } + // Save payment term type (STANDARD or EOM) + $term_type = Tools::getValue('PS_TWO_PAYMENT_TERM_TYPE'); + if ($term_type === 'STANDARD' || $term_type === 'EOM') { + Configuration::updateValue('PS_TWO_PAYMENT_TERM_TYPE', $term_type); + } + // Save payment terms checkboxes $payment_terms = array_map('strval', self::PAYMENT_TERMS_OPTIONS); foreach ($payment_terms as $term) { @@ -764,10 +871,28 @@ protected function getTwoOtherForm() ), array( 'type' => 'switch', - 'label' => $this->l('Using Own Invoices'), + 'label' => $this->l('Upload Own Invoices to Two'), 'name' => 'PS_TWO_USE_OWN_INVOICES', 'is_bool' => true, - 'desc' => $this->l('Only to be used if you are handling your own invoice and credit note distribution and must be communicated to Two as part of your implementation to ensure Two\'s invoice generation is disabled. If this toggle is enabled, PrestaShop invoices will be uploaded to Two when orders are fulfilled.'), + 'desc' => $this->l('Enable this ONLY if you are using your own invoices instead of Two\'s generated invoices. This must be coordinated with Two before enabling.') . '

' . + '' . $this->l('When enabled:') . '
' . + '• ' . $this->l('Your PrestaShop invoices will be uploaded to Two when orders are fulfilled') . '
' . + '• ' . $this->l('Two will NOT generate invoices - your invoice is used instead') . '

' . + '' . $this->l('REQUIRED: You must customize your invoice template') . '
' . + $this->l('Edit your invoice template to include Two\'s payment details FOR TWO ORDERS ONLY.') . '
' . + $this->l('Template location:') . ' /themes/YOUR_THEME/pdf/invoice.tpl ' . $this->l('or') . ' /pdf/invoice.tpl

' . + '' . $this->l('Add this code to your invoice template:') . '' . + '
' .
+                                  '{if $order->module == \'twopayment\'}
' . + '<div style="margin-top:20px; padding:15px; border:1px solid #333;">
' . + ' <strong>Payment Instructions</strong><br>
' . + ' The debt represented by this invoice has been assigned to Two.
' . + ' Please pay to Two\'s bank account (details provided by Two).
' . + ' Include your payment reference when paying.
' . + '</div>
' . + '{/if}
' . + $this->l('Two will provide you with the specific bank details and payment reference format to include.') . '

' . + '' . $this->l('Important: Contact Two support before enabling this feature.') . '', 'required' => true, 'values' => array( array( @@ -784,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') ), @@ -822,6 +947,26 @@ protected function getTwoOtherForm() ), ), ), + array( + 'type' => 'switch', + 'label' => $this->l('Enable Debug Mode'), + 'name' => 'PS_TWO_DEBUG_MODE', + 'is_bool' => true, + 'desc' => $this->l('Enable detailed logging for troubleshooting. Logs tax calculations and other diagnostic data. Only enable when requested by Two support.'), + 'required' => false, + 'values' => array( + array( + 'id' => 'PS_TWO_DEBUG_MODE_ON', + 'value' => 1, + 'label' => $this->l('Yes') + ), + array( + 'id' => 'PS_TWO_DEBUG_MODE_OFF', + 'value' => 0, + 'label' => $this->l('No') + ), + ), + ), ), 'submit' => array( 'title' => $this->l('Save'), @@ -841,9 +986,10 @@ 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; } @@ -861,8 +1007,10 @@ 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.')); } @@ -888,6 +1036,176 @@ protected function renderTwoOrderStatusForm() return $helper->generateForm(array($this->getTwoOrderStatusForm())); } + /** + * Render the Plugin Information tab content + * Displays capabilities and limitations of the Two Payment plugin + * + * @return string HTML content for the plugin information tab + */ + protected function renderTwoPluginInfo() + { + $html = ' +
+
+ ' . $this->l('What This Plugin Does') . ' +
+
+
+ ' . $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') . '

+
    +
  • ' . $this->l('Accept B2B invoice payments with instant credit approval') . '
  • +
  • ' . $this->l('Company search and validation at checkout (auto-complete)') . '
  • +
  • ' . $this->l('Real-time buyer eligibility check (Order Intent) before purchase') . '
  • +
  • ' . $this->l('Automatic order fulfillment when order status changes (configurable)') . '
  • +
  • ' . $this->l('Support for Standard and End-of-Month (EOM) payment terms') . '
  • +
  • ' . $this->l('Configurable payment terms (7, 15, 20, 30, 45, 60, 90 days)') . '
  • +
  • ' . $this->l('Handle full refunds through PrestaShop admin') . '
  • +
  • ' . $this->l('Display Two order information in admin order view') . '
  • +
  • ' . $this->l('Support for multiple tax rates and tax-exempt customers') . '
  • +
  • ' . $this->l('Handle free shipping cart rules and discounts correctly') . '
  • +
  • ' . $this->l('Works with PrestaShop 1.7.6 through 9.x') . '
  • +
+ +

' . $this->l('Important Requirements') . '

+
    +
  • ' . $this->l('Customers must have a valid company/organization number') . '
  • +
  • ' . $this->l('Customers must enter their company name in the billing address') . '
  • +
  • ' . $this->l('A valid phone number is required for credit checks') . '
  • +
  • ' . $this->l('Two must approve the buyer before the order can be placed') . '
  • +
  • ' . $this->l('Products must have correct tax rules configured in PrestaShop') . '
  • +
+ +

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

+
    +
  • ' . $this->l('Process B2C (consumer) payments - Two is B2B only') . '
  • +
  • ' . $this->l('Guarantee approval - Two performs real-time credit checks') . '
  • +
  • ' . $this->l('Override Two\'s credit decision or buyer limits') . '
  • +
  • ' . $this->l('Process partial refunds - use the Two Merchant Portal for partial refunds') . '
  • +
  • ' . $this->l('Partial fulfillment - orders must be fulfilled in full') . '
  • +
  • ' . $this->l('Fix incorrect tax configuration in your store - taxes must be set up correctly in PrestaShop') . '
  • +
  • ' . $this->l('Process orders without a valid company registration number') . '
  • +
  • ' . $this->l('Change payment terms after an order is placed') . '
  • +
+ +

' . $this->l('Troubleshooting Tips') . '

+
    +
  • ' . $this->l('Tax shows 0%?') . ' ' . $this->l('Check that tax rules are configured for your country in International > Taxes > Tax Rules') . '
  • +
  • ' . $this->l('Buyer rejected?') . ' ' . $this->l('The company may have reached their credit limit or failed Two\'s credit check') . '
  • +
  • ' . $this->l('Company not found?') . ' ' . $this->l('Customer must enter their official registered company name') . '
  • +
  • ' . $this->l('Phone invalid?') . ' ' . $this->l('Ensure the phone number includes country code and is in a valid format') . '
  • +
  • ' . $this->l('Amount mismatch errors?') . ' ' . $this->l('Enable Debug Mode in Other Settings and contact Two support with the logs') . '
  • +
+
+
+ +
+
+ ' . $this->l('Need Help?') . ' +
+
+

' . $this->l('For technical support or questions about this plugin:') . '

+ +

' . $this->l('Plugin Version:') . ' ' . $this->version . ' | ' . $this->l('PrestaShop:') . ' ' . _PS_VERSION_ . '

+
+
'; + + return $html; + } + + /** + * Render a compact operational health summary for plugin configuration. + * + * @return string HTML + */ + protected function renderTwoPluginHealthChecklist() + { + $environment = (string) Configuration::get('PS_TWO_ENVIRONMENT', 'development'); + $api_verified = (bool) Configuration::get('PS_TWO_API_KEY_VERIFIED'); + $ssl_disabled = (bool) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY'); + $order_intent_enabled = true; + $use_account_type = (bool) Configuration::get('PS_TWO_USE_ACCOUNT_TYPE'); + $term_type = (string) Configuration::get('PS_TWO_PAYMENT_TERM_TYPE', 'STANDARD'); + $available_terms = $this->getAvailablePaymentTerms(); + + $term_labels = array(); + foreach ($available_terms as $term) { + $term_labels[] = (int) $term; + } + + $status_rows = array( + array( + 'label' => $this->l('API key'), + 'value' => $api_verified ? $this->l('Verified') : $this->l('Not verified'), + 'ok' => $api_verified, + ), + array( + 'label' => $this->l('Environment'), + 'value' => strtoupper($environment), + 'ok' => true, + ), + array( + 'label' => $this->l('SSL verification'), + 'value' => $ssl_disabled ? $this->l('Disabled') : $this->l('Enabled'), + 'ok' => !$ssl_disabled, + ), + array( + 'label' => $this->l('Order intent pre-check'), + 'value' => $order_intent_enabled ? $this->l('Enabled') : $this->l('Disabled'), + 'ok' => true, + ), + array( + 'label' => $this->l('Account type mode'), + 'value' => $use_account_type ? $this->l('Enabled') : $this->l('Disabled'), + 'ok' => true, + ), + array( + 'label' => $this->l('Payment terms'), + 'value' => $term_type . ' (' . implode(', ', $term_labels) . ')', + 'ok' => !empty($term_labels), + ), + ); + + $html = '
'; + $html .= '
' . $this->l('Current Configuration Health') . '
'; + $html .= '
'; + $html .= '
'; + + foreach ($status_rows as $row) { + $status_class = $row['ok'] ? 'text-success' : 'text-warning'; + $status_icon = $row['ok'] ? 'icon-check-circle' : 'icon-warning'; + $html .= '
' . $row['label'] . ': ' . $row['value'] . '
'; + } + + $html .= '
'; + + if ($environment === 'production' && $ssl_disabled) { + $html .= '
'; + $html .= '' . $this->l('Security warning:') . ' '; + $html .= $this->l('SSL verification is disabled in production. Re-enable it unless your network requires a trusted corporate proxy setup.'); + $html .= '
'; + } + + if (!$api_verified) { + $html .= '
'; + $html .= '' . $this->l('Action required:') . ' '; + $html .= $this->l('API key is not verified. Checkout requests may fail until the General Settings are saved with a valid key.'); + $html .= '
'; + } + + $html .= '
'; + + return $html; + } + protected function getTwoOrderStatusForm() { // Get all available PrestaShop order states for mapping @@ -1243,12 +1561,18 @@ public function hookActionOrderStatusUpdate($params) $this->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/cancel', [], 'POST'); $response = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); if (isset($response['id']) && $response['id']) { + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse( + $response, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->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' => (string)$this->getSelectedPaymentTerm(), // Selected payment term + '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' => isset($response['invoice_details']['id']) ? $response['invoice_details']['id'] : (isset($orderpaymentdata['two_invoice_id']) ? $orderpaymentdata['two_invoice_id'] : null), ); @@ -1258,6 +1582,18 @@ public function hookActionOrderStatusUpdate($params) // Complete fulfillment using the new fulfillments endpoint - wrapped in try-catch for safety try { PrestaShopLogger::addLog('TwoPayment: Initiating complete fulfillment for Two order ID: ' . $two_order_id . ', Order ID: ' . $id_order . ', Triggered by status: ' . $new_order_status->name . ' (ID: ' . $new_order_status->id . ')', 1); + + $stored_two_state = isset($orderpaymentdata['two_order_state']) ? strtoupper(trim((string)$orderpaymentdata['two_order_state'])) : ''; + if ($this->shouldBlockTwoFulfillmentByTwoState($stored_two_state)) { + $this->applyTwoCancelledOrderStateProfileToStatusObject($new_order_status, (int)$order->id_lang); + $this->addTwoBackOfficeWarning($this->l('Fulfillment blocked: this Two order is cancelled at provider. The order status has been reverted to cancelled.')); + PrestaShopLogger::addLog( + 'TwoPayment: Fulfillment blocked for cancelled Two order ' . $two_order_id . + ' (stored state=' . $stored_two_state . '). Fulfillment status change will be forced to cancelled for order ' . $id_order, + 2 + ); + return; + } // Validate order state before attempting fulfillment $current_two_order = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); @@ -1265,11 +1601,39 @@ public function hookActionOrderStatusUpdate($params) PrestaShopLogger::addLog('TwoPayment: Cannot retrieve Two order state for fulfillment. Two order ID: ' . $two_order_id, 3); return; } + + $provider_two_state = strtoupper(trim((string)$current_two_order['state'])); + if ($this->shouldBlockTwoFulfillmentByTwoState($provider_two_state)) { + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse( + $current_two_order, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->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' => $two_order_id, + 'two_order_reference' => isset($current_two_order['merchant_reference']) ? $current_two_order['merchant_reference'] : (isset($orderpaymentdata['two_order_reference']) ? $orderpaymentdata['two_order_reference'] : ''), + 'two_order_state' => $provider_two_state, + 'two_order_status' => isset($current_two_order['status']) ? $current_two_order['status'] : (isset($orderpaymentdata['two_order_status']) ? $orderpaymentdata['two_order_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($current_two_order['invoice_url']) ? $current_two_order['invoice_url'] : (isset($orderpaymentdata['two_invoice_url']) ? $orderpaymentdata['two_invoice_url'] : ''), + 'two_invoice_id' => isset($current_two_order['invoice_details']['id']) ? $current_two_order['invoice_details']['id'] : (isset($orderpaymentdata['two_invoice_id']) ? $orderpaymentdata['two_invoice_id'] : null), + ); + $this->setTwoOrderPaymentData((int)$id_order, $payment_data); + $this->applyTwoCancelledOrderStateProfileToStatusObject($new_order_status, (int)$order->id_lang); + $this->addTwoBackOfficeWarning($this->l('Fulfillment blocked: this Two order is cancelled at provider. The order status has been reverted to cancelled.')); + PrestaShopLogger::addLog( + 'TwoPayment: Fulfillment blocked for cancelled Two order ' . $two_order_id . + ' (provider state=' . $provider_two_state . '). Fulfillment status change will be forced to cancelled for order ' . $id_order, + 2 + ); + return; + } // Only attempt fulfillment if order is in CONFIRMED state // Only CONFIRMED orders can be fulfilled (VERIFIED orders must be confirmed first to ensure they have been sent to the checkout success page) - if ($current_two_order['state'] !== 'CONFIRMED') { - PrestaShopLogger::addLog('TwoPayment: Two order not in fulfillable state. Current state: ' . $current_two_order['state'] . ', Expected: CONFIRMED. Two order ID: ' . $two_order_id, 2); + if (!$this->isTwoOrderFulfillableState($provider_two_state)) { + PrestaShopLogger::addLog('TwoPayment: Two order not in fulfillable state. Current state: ' . $provider_two_state . ', Expected: CONFIRMED. Two order ID: ' . $two_order_id, 2); return; } @@ -1280,22 +1644,48 @@ public function hookActionOrderStatusUpdate($params) // Refresh order data from Two to avoid overwriting the stored Two order ID with fulfillment ID $order_after = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); if (isset($order_after['id']) && $order_after['id']) { + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse( + $order_after, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->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' => $two_order_id, 'two_order_reference' => isset($order_after['merchant_reference']) ? $order_after['merchant_reference'] : (isset($orderpaymentdata['two_order_reference']) ? $orderpaymentdata['two_order_reference'] : ''), 'two_order_state' => isset($order_after['state']) ? $order_after['state'] : (isset($orderpaymentdata['two_order_state']) ? $orderpaymentdata['two_order_state'] : ''), 'two_order_status' => isset($order_after['status']) ? $order_after['status'] : (isset($orderpaymentdata['two_order_status']) ? $orderpaymentdata['two_order_status'] : ''), - 'two_day_on_invoice' => (string)$this->getSelectedPaymentTerm(), // Selected payment term + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], 'two_invoice_url' => isset($order_after['invoice_url']) ? $order_after['invoice_url'] : (isset($orderpaymentdata['two_invoice_url']) ? $orderpaymentdata['two_invoice_url'] : ''), 'two_invoice_id' => isset($order_after['invoice_details']['id']) ? $order_after['invoice_details']['id'] : (isset($orderpaymentdata['two_invoice_id']) ? $orderpaymentdata['two_invoice_id'] : null), ); + // Note: invoice_details (payment info) is NOT stored in DB - it's fetched from Two API when needed + // This ensures payment details are always current and avoids DB schema changes + $this->setTwoOrderPaymentData($id_order, $payment_data); } - // INVOICE UPLOAD: If "Using Own Invoices" is enabled, upload PrestaShop invoice to Two - if (Configuration::get('PS_TWO_USE_OWN_INVOICES')) { + // Invoice Upload: Upload PrestaShop invoice to Two when using own invoices + $use_own_invoices = Configuration::get('PS_TWO_USE_OWN_INVOICES'); + PrestaShopLogger::addLog( + 'TwoPayment: Invoice upload check - PS_TWO_USE_OWN_INVOICES=' . ($use_own_invoices ? 'YES' : 'NO') . ', Order ID=' . $id_order, + 1, + null, + 'Order', + $id_order + ); + + if ($use_own_invoices) { // Re-fetch payment data to ensure we have the latest invoice_id $orderpaymentdata_refreshed = $this->getTwoOrderPaymentData($id_order); + PrestaShopLogger::addLog( + 'TwoPayment: Triggering invoice upload - two_invoice_id=' . + (isset($orderpaymentdata_refreshed['two_invoice_id']) ? $orderpaymentdata_refreshed['two_invoice_id'] : 'NOT SET'), + 1, + null, + 'Order', + $id_order + ); $this->uploadInvoiceAfterFulfillment($id_order, $orderpaymentdata_refreshed); } } else { @@ -1306,7 +1696,13 @@ public function hookActionOrderStatusUpdate($params) } elseif (isset($response['message'])) { $error_message = $response['message']; } - PrestaShopLogger::addLog('TwoPayment: Fulfillment failed for Two order ID: ' . $two_order_id . ', Error: ' . $error_message . ', Response: ' . json_encode($response), 3); + $response_summary = $this->buildTwoApiResponseLogSummary($response); + PrestaShopLogger::addLog( + 'TwoPayment: Fulfillment failed for Two order ID: ' . $two_order_id . + ', Error: ' . $error_message . + ', Response Summary: ' . json_encode($response_summary), + 3 + ); // Don't interfere with PrestaShop's status change process // Just log the error - admin can check logs for fulfillment issues @@ -1388,12 +1784,18 @@ public function hookActionOrderStatusUpdate($params) // Fetch latest order snapshot to update local state/status $order_after = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); if (isset($order_after['id']) && $order_after['id']) { + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse( + $order_after, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->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' => $two_order_id, 'two_order_reference' => isset($order_after['merchant_reference']) ? $order_after['merchant_reference'] : (isset($orderpaymentdata['two_order_reference']) ? $orderpaymentdata['two_order_reference'] : ''), 'two_order_state' => isset($order_after['state']) ? $order_after['state'] : (isset($orderpaymentdata['two_order_state']) ? $orderpaymentdata['two_order_state'] : ''), 'two_order_status' => isset($order_after['status']) ? $order_after['status'] : (isset($orderpaymentdata['two_order_status']) ? $orderpaymentdata['two_order_status'] : ''), - 'two_day_on_invoice' => (string)$this->getSelectedPaymentTerm(), + 'two_day_on_invoice' => $resolved_terms['two_day_on_invoice'], + 'two_payment_term_type' => $resolved_terms['two_payment_term_type'], 'two_invoice_url' => isset($order_after['invoice_url']) ? $order_after['invoice_url'] : (isset($orderpaymentdata['two_invoice_url']) ? $orderpaymentdata['two_invoice_url'] : ''), 'two_invoice_id' => isset($order_after['invoice_details']['id']) ? $order_after['invoice_details']['id'] : (isset($orderpaymentdata['two_invoice_id']) ? $orderpaymentdata['two_invoice_id'] : null), ); @@ -1414,7 +1816,7 @@ public function hookActionOrderStatusUpdate($params) $log_message .= ', HTTP Status: ' . ($http_status > 0 ? $http_status : 'Unknown'); $log_message .= ', Error: ' . $error_message; $log_message .= ', Idempotency Key: ' . $idempotency_key; - $log_message .= ', Response: ' . json_encode($response); + $log_message .= ', Response Summary: ' . json_encode($this->buildTwoApiResponseLogSummary($response)); PrestaShopLogger::addLog($log_message, 3); @@ -1440,6 +1842,85 @@ public function hookActionOrderStatusUpdate($params) } } + /** + * Intercept pending order-history inserts and force cancelled status when + * a cancelled Two order is incorrectly moved to a blocked forward-processing state + * (verified-ready or fulfillment-trigger states). + * + * @param array $params + * @return void + */ + public function hookActionObjectOrderHistoryAddBefore($params) + { + if (!is_array($params) || !isset($params['object']) || !is_object($params['object'])) { + return; + } + + $history = $params['object']; + if (!isset($history->id_order) || !isset($history->id_order_state)) { + return; + } + + $id_order = (int)$history->id_order; + $target_status = (int)$history->id_order_state; + if ($id_order <= 0 || !$this->shouldBlockTwoStatusTransitionByCancelledState($target_status)) { + return; + } + + $order = new Order($id_order); + if (!Validate::isLoadedObject($order) || !isset($order->module) || $order->module !== $this->name) { + return; + } + + $latest_attempt = $this->getLatestTwoCheckoutAttemptByOrder($id_order); + $attempt_status = is_array($latest_attempt) && isset($latest_attempt['status']) ? (string)$latest_attempt['status'] : ''; + if ($this->isTwoAttemptStatusTerminal($attempt_status)) { + $attempt_two_order_id = is_array($latest_attempt) && isset($latest_attempt['two_order_id']) ? (string)$latest_attempt['two_order_id'] : ''; + $this->forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, $attempt_two_order_id, 'attempt', 'CANCELLED'); + return; + } + + $orderpaymentdata = $this->getTwoOrderPaymentData($id_order); + if (!is_array($orderpaymentdata) || !isset($orderpaymentdata['two_order_id']) || Tools::isEmpty($orderpaymentdata['two_order_id'])) { + return; + } + + $two_order_id = $orderpaymentdata['two_order_id']; + $stored_two_state = isset($orderpaymentdata['two_order_state']) ? strtoupper(trim((string)$orderpaymentdata['two_order_state'])) : ''; + if ($this->shouldBlockTwoFulfillmentByTwoState($stored_two_state)) { + $this->forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, $two_order_id, 'stored', $stored_two_state); + return; + } + + $current_two_order = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, [], 'GET'); + if (!is_array($current_two_order) || !isset($current_two_order['state'])) { + return; + } + + $provider_two_state = strtoupper(trim((string)$current_two_order['state'])); + if (!$this->shouldBlockTwoFulfillmentByTwoState($provider_two_state)) { + return; + } + + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse( + $current_two_order, + isset($orderpaymentdata['two_day_on_invoice']) ? (string)$orderpaymentdata['two_day_on_invoice'] : (string)$this->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' => isset($current_two_order['id']) ? $current_two_order['id'] : $two_order_id, + 'two_order_reference' => isset($current_two_order['merchant_reference']) ? $current_two_order['merchant_reference'] : (isset($orderpaymentdata['two_order_reference']) ? $orderpaymentdata['two_order_reference'] : ''), + 'two_order_state' => $provider_two_state, + 'two_order_status' => isset($current_two_order['status']) ? $current_two_order['status'] : (isset($orderpaymentdata['two_order_status']) ? $orderpaymentdata['two_order_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($current_two_order['invoice_url']) ? $current_two_order['invoice_url'] : (isset($orderpaymentdata['two_invoice_url']) ? $orderpaymentdata['two_invoice_url'] : ''), + 'two_invoice_id' => isset($current_two_order['invoice_details']['id']) ? $current_two_order['invoice_details']['id'] : (isset($orderpaymentdata['two_invoice_id']) ? $orderpaymentdata['two_invoice_id'] : null), + ); + $this->setTwoOrderPaymentData($id_order, $payment_data); + $this->forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, $two_order_id, 'provider', $provider_two_state); + } + public function hookActionFrontControllerSetMedia() { // CRITICAL FIX: Only load Two assets on checkout pages to prevent conflicts and improve performance @@ -1492,30 +1973,14 @@ public function hookActionFrontControllerSetMedia() ); } - // Layer 3: GUARANTEED CDN fallback (critical for PrestaShop 1.7.6.5 compatibility) - // This ensures jQuery loads even when PrestaShop's methods fail silently - // Uses official jQuery CDN with crossorigin for security - try { - $this->context->controller->addJS( - 'https://code.jquery.com/jquery-3.6.0.min.js', - false // Load in HEAD before other scripts - ); - } catch (Exception $e) { - PrestaShopLogger::addLog( - 'Two Payment: CDN jQuery fallback failed - ' . $e->getMessage(), - 3, // Error level - this is critical - null, - 'Module', - $this->id - ); - } + // Layer 3 moved to frontend runtime: local same-origin jQuery fallback in twopayment.js. + // This avoids remote CDN dependency while preserving legacy compatibility behavior. $countries = Country::getCountries($this->context->language->id, false, false, false); $param_countries = array(); foreach ($countries as $country) { $param_countries[$country['id_country']] = Tools::strtolower($country['iso_code']); } - // Build FE i18n (strings are translated by PrestaShop according to current language) $i18n = array( 'checking_eligibility' => $this->l('Checking Two payment eligibility...'), @@ -1526,16 +1991,37 @@ public function hookActionFrontControllerSetMedia() 'payment_approved_message' => $this->l('Payment approved! Choose your payment terms below.'), 'payment_not_available_message' => $this->l('Two payment is not available for this order.'), 'generic_error' => $this->l('There was an issue processing your Two payment request. Please try again or choose another payment method.'), + 'order_intent_check_failed' => $this->l('Order intent check failed'), + 'invalid_response_from_server' => $this->l('Invalid response from server'), + 'choose_payment_terms' => $this->l('Choose the Buy Now, Pay Later option that works best for you'), + 'payment_period_starts' => $this->l('Your payment period starts when your order is fulfilled'), 'invoice_likely_accepted_for' => $this->l('Your invoice with Two is likely to be accepted for %s'), 'invoice_cannot_be_approved_for' => $this->l('Your invoice with Two cannot be approved at this time for %s'), - 'company_name_required' => $this->l('Please enter your company name to continue with Two payment.'), + 'invoice_likely_accepted' => $this->l('Your invoice with Two is likely to be accepted'), + 'invoice_cannot_be_approved' => $this->l('Your invoice with Two cannot be approved at this time'), + 'invalid_phone_number' => $this->l('The phone number in your billing address appears to be invalid. Please go back and ensure you have entered a valid phone number for your country.'), + 'company_name_required' => $this->l('To pay with Two, go back to your billing address and enter your company name in the Company field.'), + 'company_name_required_business' => $this->l('Company name is required for business accounts.'), 'organization_number_required' => $this->l('Please search and select a valid company to continue with Two payment.'), - 'select_company_to_use_two' => $this->l('To pay with Two, please select your company from the search results so we can verify your business and offer invoice terms.'), + 'select_company_to_use_two' => $this->l('To pay with Two, go back to your billing address and search for your company name. Select your company from the results to verify your business.'), 'invalid_company' => $this->l('The company information provided is not valid. Please search and select a valid company.'), 'company_not_found' => $this->l('We could not find your company. Please try a different company name or contact support.'), 'credit_unavailable' => $this->l('Two payment is not available for this order. Please choose another payment method.'), 'network_issue' => $this->l('There was a temporary issue verifying your payment. Please try again or choose another payment method.'), + 'resolve_payment_issue_before_continuing' => $this->l('Please resolve the payment issue before continuing.'), 'approval_required' => $this->l('Payment approval required before proceeding'), + 'invoice_declined' => $this->l('Your invoice with Two cannot be approved at this time. Please select an alternative payment method.'), + 'invalid_email' => $this->l('The email address provided is invalid. Please check your email and try again.'), + 'invalid_address' => $this->l('The address provided is invalid. Please go back and verify your billing address details.'), + 'company_incomplete' => $this->l('Company information is incomplete. Go back to your billing address and select your company from the search results.'), + 'validation_error' => $this->l('Some of the information provided is invalid. Please check your billing address details and try again.'), + 'company_verify_failed' => $this->l('Company information could not be verified. Go back to your billing address and select your company from the search results.'), + 'company_verification_needed' => $this->l('Company Verification Needed'), + 'company_auto_resolve_hint' => $this->l('We found your company name but need you to verify it. Please go back to your billing address and select your company from the search results.'), + 'pay_in' => $this->l('Pay in'), + 'days' => $this->l('days'), + 'from_end_of_month' => $this->l('from end of month'), + 'end_of_month_plus_days' => $this->l('End of Month + %s days'), ); Media::addJsDef(array('twopayment' => array( @@ -1555,6 +2041,7 @@ public function hookActionFrontControllerSetMedia() 'countries' => $param_countries, 'available_payment_terms' => $this->getAvailablePaymentTerms(), 'default_payment_term' => $this->getDefaultPaymentTerm(), + 'payment_term_type' => Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'), 'i18n' => $i18n, 'phone_i18n' => array( 'invalid_number' => $this->l('Invalid phone number'), @@ -1578,6 +2065,46 @@ public function hookActionFrontControllerSetMedia() $this->context->controller->registerJavascript('two-script', 'modules/twopayment/views/js/twopayment.js', array('priority' => 206, 'async' => false)); } + /** + * Back-office media hook. + * Loads module admin styling for order widgets and module configuration views. + */ + public function hookActionAdminControllerSetMedia() + { + if (!isset($this->context->controller) || !is_object($this->context->controller)) { + return; + } + + $controller = $this->context->controller; + $controller_name = isset($controller->controller_name) ? (string) $controller->controller_name : ''; + $php_self = isset($controller->php_self) ? (string) $controller->php_self : ''; + $request_controller = (string) Tools::getValue('controller'); + $configure_module = (string) Tools::getValue('configure'); + + $is_module_config_page = ($configure_module === $this->name); + $is_order_admin_page = (stripos($controller_name, 'Order') !== false) + || (stripos($php_self, 'order') !== false) + || (stripos($request_controller, 'Order') !== false); + + if (!$is_module_config_page && !$is_order_admin_page) { + return; + } + + if (method_exists($controller, 'registerStylesheet')) { + $controller->registerStylesheet( + 'module-twopayment-admin-css', + 'modules/twopayment/views/css/two.css', + array('media' => 'all', 'priority' => 200) + ); + + return; + } + + if (method_exists($controller, 'addCSS')) { + $controller->addCSS($this->_path . 'views/css/two.css'); + } + } + public function hookPaymentOptions($params) { if (!$this->active) { @@ -1601,13 +2128,22 @@ public function hookPaymentOptions($params) return []; } + if (!$this->checkCurrency($cart)) { + PrestaShopLogger::addLog( + 'TwoPayment: Payment option hidden - unsupported cart currency for cart ' . (int)$cart->id, + 2 + ); + return []; + } + // If merchant uses account type selection, gate payment option to business accounts if ((int) Configuration::get('PS_TWO_USE_ACCOUNT_TYPE')) { - if (empty($billing_address->account_type) || $billing_address->account_type !== 'business') { - PrestaShopLogger::addLog('TwoPayment: Payment option hidden - account type is not business (current: ' . ($billing_address->account_type ?: 'not set') . ')', 1); + $account_type = property_exists($billing_address, 'account_type') ? trim((string) $billing_address->account_type) : ''; + if ($account_type !== 'business') { + PrestaShopLogger::addLog('TwoPayment: Payment option hidden - account type is not business (current: ' . ($account_type ?: 'not set') . ')', 1); return []; } - PrestaShopLogger::addLog('TwoPayment: Payment option shown for business account', 1); + PrestaShopLogger::addLog('TwoPayment: Payment option shown for business account path', 1); } else { // When account type selection is disabled, allow showing Two option; FE will prompt for company selection as needed PrestaShopLogger::addLog('TwoPayment: Payment option shown (account type disabled)', 1); @@ -1652,74 +2188,632 @@ protected function getTwoPaymentOption() return $preTwoOption; } + /** + * Check if cart currency is allowed for this module according to PrestaShop payment restrictions. + * + * @param Cart $cart + * @return bool + */ + private function checkCurrency($cart) + { + if (!Validate::isLoadedObject($cart) || !isset($cart->id_currency) || (int)$cart->id_currency <= 0) { + return false; + } + + $currency_order = new Currency((int)$cart->id_currency); + if (!Validate::isLoadedObject($currency_order)) { + return false; + } + // Enforce provider-supported currency ISO list first, then apply PrestaShop module assignment check. + // This keeps behavior explicit and documents covered currencies in-code. + $currency_iso = strtoupper(trim((string)$currency_order->iso_code)); + if (Tools::isEmpty($currency_iso) || !in_array($currency_iso, self::TWO_SUPPORTED_CURRENCY_ISOS, true)) { + return false; + } - public function getTwoIntentOrderData($cart, $customer, $currency, $address) - { - // Validate cart has products before building order data - if (!Validate::isLoadedObject($cart) || $cart->nbProducts() <= 0) { - PrestaShopLogger::addLog('TwoPayment: Cannot build order intent - cart is empty or invalid', 3); - throw new Exception('Cart is empty or invalid'); + if (!method_exists($this, 'getCurrency')) { + return true; } - - // Get line items (using PrestaShop's native values) + + $currencies_module = $this->getCurrency((int)$cart->id_currency); + if (empty($currencies_module)) { + return false; + } + + foreach ($currencies_module as $currency_module) { + if (isset($currency_module['id_currency']) && (int)$currency_module['id_currency'] === (int)$currency_order->id) { + return true; + } + } + + return false; + } + + /** + * Public wrapper for currency compatibility checks used by front controllers. + * + * @param Cart $cart + * @return bool + */ + public function isCartCurrencySupportedByTwo($cart) + { + return $this->checkCurrency($cart); + } + + /** + * Build shared pricing data for Two payloads from a single line-item source. + * + * @param Cart $cart + * @param string $contextLabel + * @return array + * @throws Exception + */ + private function buildTwoOrderPricingData($cart, $contextLabel = 'order payload', $strictReconciliation = false) + { $line_items = $this->getTwoProductItems($cart); - - // Validate we have line items if (empty($line_items)) { - PrestaShopLogger::addLog('TwoPayment: Cannot build order intent - no valid line items', 3); + PrestaShopLogger::addLog('TwoPayment: Cannot build ' . $contextLabel . ' - no valid line items', 3); throw new Exception('No valid line items in cart'); } - - // Calculate tax subtotals from line items first + + if (!$this->validateTwoLineItems($line_items)) { + PrestaShopLogger::addLog('TwoPayment: Cannot build ' . $contextLabel . ' - invalid line item formulas', 3); + throw new Exception('Invalid line item formulas'); + } + + $lineTotals = $this->calculateTwoLineItemTotals($line_items); + $max_reconciliation_diff_cents = 0; + if (!$this->validateTwoOrderReconciliationAgainstCart($cart, $lineTotals, $contextLabel, $max_reconciliation_diff_cents)) { + if ($this->shouldBlockOnReconciliationDrift($contextLabel, $max_reconciliation_diff_cents, (bool)$strictReconciliation)) { + PrestaShopLogger::addLog( + 'TwoPayment: ' . $contextLabel . ' blocked by reconciliation policy. ' . + 'Max drift=' . $this->getTwoRoundAmount($max_reconciliation_diff_cents / 100) . + ', Tolerance=' . $this->getTwoRoundAmount(self::ORDER_RECONCILIATION_TOLERANCE), + 3 + ); + throw new Exception('Order totals do not reconcile with cart totals'); + } + + PrestaShopLogger::addLog( + 'TwoPayment: ' . $contextLabel . ' reconciliation drift logged as warning-only (intent precheck path).', + 2 + ); + } + $tax_subtotals = $this->getTwoTaxSubtotals($line_items); - - // Calculate totals from tax_subtotals to ensure exact match - $totals = $this->calculateOrderTotalsFromTaxSubtotals($tax_subtotals); - $final_net = $totals['net']; - $final_tax = $totals['tax']; - $final_gross = $totals['gross']; - - // Get discount amount from PrestaShop (Two API expects positive discount amount) - $final_discount = abs((float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS)); - - // Get company data with fallback chain - $companyData = $this->getCompanyDataWithFallbacks($address); + $subtotalsTotals = $this->calculateOrderTotalsFromTaxSubtotals($tax_subtotals); + if ( + !$this->isTwoAmountWithinTolerance($lineTotals['net'], $subtotalsTotals['net']) || + !$this->isTwoAmountWithinTolerance($lineTotals['tax'], $subtotalsTotals['tax']) || + !$this->isTwoAmountWithinTolerance($lineTotals['gross'], $subtotalsTotals['gross']) + ) { + PrestaShopLogger::addLog( + 'TwoPayment: Cannot build ' . $contextLabel . ' - tax subtotals mismatch line totals. ' . + 'Line(net/tax/gross)=(' . $this->getTwoRoundAmount($lineTotals['net']) . '/' . + $this->getTwoRoundAmount($lineTotals['tax']) . '/' . + $this->getTwoRoundAmount($lineTotals['gross']) . ') vs Subtotals=(' . + $this->getTwoRoundAmount($subtotalsTotals['net']) . '/' . + $this->getTwoRoundAmount($subtotalsTotals['tax']) . '/' . + $this->getTwoRoundAmount($subtotalsTotals['gross']) . ')', + 3 + ); + throw new Exception('Tax subtotals do not reconcile with line items'); + } - $request_data = [ - 'gross_amount' => (string)($this->getTwoRoundAmount($final_gross)), - 'net_amount' => (string)($this->getTwoRoundAmount($final_net)), - 'tax_amount' => (string)($this->getTwoRoundAmount($final_tax)), - 'discount_amount' => (string)($this->getTwoRoundAmount($final_discount)), - 'tax_subtotals' => $tax_subtotals, - 'buyer' => [ - 'company' => [ - 'company_name' => $companyData['company_name'], - 'country_prefix' => $companyData['country_iso'], - 'organization_number' => $companyData['organization_number'], - 'website' => '', - ], - 'representative' => [ - 'email' => $customer->email, - 'first_name' => $customer->firstname, - 'last_name' => $customer->lastname, - 'phone_number' => $address->phone, - ], - ], - 'currency' => $currency->iso_code, - 'merchant_short_name' => $this->merchant_short_name, - 'invoice_type' => 'FUNDED_INVOICE', // Default product type + return [ 'line_items' => $line_items, + 'tax_subtotals' => $tax_subtotals, + 'net_amount' => $subtotalsTotals['net'], + 'tax_amount' => $subtotalsTotals['tax'], + 'gross_amount' => $subtotalsTotals['gross'], + 'discount_amount' => abs((float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS)), ]; + } + + /** + * Sum line-item monetary fields with stable 2-decimal arithmetic. + * + * @param array $line_items + * @return array + */ + private function calculateTwoLineItemTotals($line_items) + { + $net = 0.0; + $tax = 0.0; + $gross = 0.0; + foreach ($line_items as $item) { + $net = round($net + (float)(isset($item['net_amount']) ? $item['net_amount'] : 0), 2); + $tax = round($tax + (float)(isset($item['tax_amount']) ? $item['tax_amount'] : 0), 2); + $gross = round($gross + (float)(isset($item['gross_amount']) ? $item['gross_amount'] : 0), 2); + } + + return [ + 'net' => $net, + 'tax' => $tax, + 'gross' => $gross, + ]; + } + + /** + * Validate line-based totals against cart totals before sending to Two. + * + * @param Cart $cart + * @param array $lineTotals + * @param string $contextLabel + * @return bool + */ + private function validateTwoOrderReconciliationAgainstCart($cart, $lineTotals, $contextLabel, &$maxDiffCents = 0) + { + $maxDiffCents = 0; + $lineNet = round((float)$lineTotals['net'], 2); + $lineTax = round((float)$lineTotals['tax'], 2); + $lineGross = round((float)$lineTotals['gross'], 2); + + if (!$this->isTwoAmountWithinTolerance($lineGross, $lineNet + $lineTax)) { + $maxDiffCents = PHP_INT_MAX; + PrestaShopLogger::addLog( + 'TwoPayment: ' . $contextLabel . ' reconciliation mismatch - line totals fail gross equation. ' . + 'gross=' . $this->getTwoRoundAmount($lineGross) . ', net+tax=' . + $this->getTwoRoundAmount($lineNet + $lineTax), + 3 + ); + return false; + } + + $cartGross = round((float)$cart->getOrderTotal(true, Cart::BOTH), 2); + $cartNet = round((float)$cart->getOrderTotal(false, Cart::BOTH), 2); + if ($cart->nbProducts() > 0 && $cartGross == 0.0 && $cartNet == 0.0) { + PrestaShopLogger::addLog( + 'TwoPayment: Cart totals unavailable for ' . $contextLabel . '; skipping strict reconciliation gate.', + 2 + ); + return true; + } + + $cartTax = round($cartGross - $cartNet, 2); + $grossDiff = abs($lineGross - $cartGross); + $netDiff = abs($lineNet - $cartNet); + $taxDiff = abs($lineTax - $cartTax); + + // Compare in cents to avoid float boundary artifacts (e.g. visible 0.02 diff treated as 0.0200000001). + $toleranceCents = $this->convertAmountToCents(self::ORDER_RECONCILIATION_TOLERANCE); + $grossDiffCents = $this->convertAmountToCents($grossDiff); + $netDiffCents = $this->convertAmountToCents($netDiff); + $taxDiffCents = $this->convertAmountToCents($taxDiff); + $maxDiffCents = max($grossDiffCents, $netDiffCents, $taxDiffCents); + + if ( + $grossDiffCents > $toleranceCents || + $netDiffCents > $toleranceCents || + $taxDiffCents > $toleranceCents + ) { + PrestaShopLogger::addLog( + 'TwoPayment: ' . $contextLabel . ' reconciliation mismatch - order totals mismatch cart totals. ' . + 'Line(net/tax/gross)=(' . $this->getTwoRoundAmount($lineNet) . '/' . + $this->getTwoRoundAmount($lineTax) . '/' . + $this->getTwoRoundAmount($lineGross) . '), ' . + 'Cart=(' . $this->getTwoRoundAmount($cartNet) . '/' . + $this->getTwoRoundAmount($cartTax) . '/' . + $this->getTwoRoundAmount($cartGross) . '), ' . + 'Diff=(' . $this->getTwoRoundAmount($netDiffCents / 100) . '/' . + $this->getTwoRoundAmount($taxDiffCents / 100) . '/' . + $this->getTwoRoundAmount($grossDiffCents / 100) . ')', + 3 + ); + return false; + } + + return true; + } + + /** + * Decide whether reconciliation drift should block local payload generation. + * Order intent remains permissive; create/update only block on material mismatches. + * + * @param string $contextLabel + * @param int $maxDiffCents + * @return bool + */ + private function shouldBlockOnReconciliationDrift($contextLabel, $maxDiffCents, $strictReconciliation = false) + { + if ((bool)$strictReconciliation) { + $strictToleranceCents = $this->convertAmountToCents(self::ORDER_RECONCILIATION_TOLERANCE); + return (int)$maxDiffCents > $strictToleranceCents; + } + + if (strpos($contextLabel, 'order intent') !== false) { + return false; + } + + // Create/update payloads must fail-closed when drift exceeds default tolerance. + $createToleranceCents = $this->convertAmountToCents(self::ORDER_RECONCILIATION_TOLERANCE); + return (int)$maxDiffCents > $createToleranceCents; + } + + /** + * Normalize decimal amount to integer cents for stable boundary comparisons. + * + * @param float|int|string $amount + * @return int + */ + private function convertAmountToCents($amount) + { + return (int) round(round((float)$amount, 2) * 100); + } + + /** + * Amount comparison helper with configurable tolerance. + * + * @param float $left + * @param float $right + * @param float|null $tolerance + * @return bool + */ + private function isTwoAmountWithinTolerance($left, $right, $tolerance = null) + { + if ($tolerance === null) { + $tolerance = self::ORDER_RECONCILIATION_TOLERANCE; + } + + return abs(round((float)$left, 2) - round((float)$right, 2)) <= (float)$tolerance; + } + + + + public function getTwoIntentOrderData($cart, $customer, $currency, $address) + { + return $this->buildTwoIntentOrderData($cart, $customer, $currency, $address, false); + } + + /** + * Build order intent payload with selectable reconciliation strictness. + * + * @param Cart $cart + * @param Customer $customer + * @param Currency $currency + * @param Address $address + * @param bool $strictReconciliation + * @return array + */ + private function buildTwoIntentOrderData($cart, $customer, $currency, $address, $strictReconciliation) + { + // Validate cart has products before building order data + if (!Validate::isLoadedObject($cart) || $cart->nbProducts() <= 0) { + PrestaShopLogger::addLog('TwoPayment: Cannot build order intent - cart is empty or invalid', 3); + throw new Exception('Cart is empty or invalid'); + } + + $contextLabel = (bool)$strictReconciliation ? 'order intent strict submit' : 'order intent'; + // Order intent pre-check remains permissive for UX refresh checks. + // Payment-submit authoritative intent checks must be strict. + $pricingData = $this->buildTwoOrderPricingData($cart, $contextLabel, (bool)$strictReconciliation); + $line_items = $pricingData['line_items']; + $tax_subtotals = $pricingData['tax_subtotals']; + $final_net = $pricingData['net_amount']; + $final_tax = $pricingData['tax_amount']; + $final_gross = $pricingData['gross_amount']; + $final_discount = $pricingData['discount_amount']; + + // Resolve invoice/shipping addresses for parity with create/update payloads. + $invoice_address = Validate::isLoadedObject($address) ? $address : new Address((int)$cart->id_address_invoice); + if (!Validate::isLoadedObject($invoice_address)) { + PrestaShopLogger::addLog('TwoPayment: Cannot build order intent - invalid invoice address', 3); + throw new Exception('Invalid invoice address'); + } + + $delivery_address = new Address((int)$cart->id_address_delivery); + if (!Validate::isLoadedObject($delivery_address)) { + $delivery_address = $invoice_address; + } + + // Get company data with fallback chain + $companyData = $this->getCompanyDataWithFallbacks($invoice_address); + $shippingData = $companyData; + try { + $shippingData = $this->getCompanyDataWithFallbacks($delivery_address); + } catch (Exception $e) { + PrestaShopLogger::addLog( + 'TwoPayment: Order intent shipping company fallback used due to address resolution error - ' . $e->getMessage(), + 2 + ); + $delivery_address = $invoice_address; + } + $shippingOrgName = !empty($shippingData['company_name']) ? $shippingData['company_name'] : $companyData['company_name']; + + $request_data = [ + 'gross_amount' => (string)($this->getTwoRoundAmount($final_gross)), + 'net_amount' => (string)($this->getTwoRoundAmount($final_net)), + 'tax_amount' => (string)($this->getTwoRoundAmount($final_tax)), + 'discount_amount' => (string)($this->getTwoRoundAmount($final_discount)), + 'buyer' => [ + 'company' => [ + 'company_name' => $companyData['company_name'], + 'country_prefix' => $companyData['country_iso'], + 'organization_number' => $companyData['organization_number'], + 'website' => '', + ], + 'representative' => [ + 'email' => $customer->email, + 'first_name' => $customer->firstname, + 'last_name' => $customer->lastname, + 'phone_number' => $this->getPhoneWithFallback($invoice_address), + ], + ], + 'currency' => $currency->iso_code, + 'merchant_short_name' => $this->merchant_short_name, + 'invoice_type' => 'FUNDED_INVOICE', // Default product type + 'billing_address' => $this->buildTwoAddress($invoice_address, $companyData['company_name'], $companyData['country_iso']), + 'shipping_address' => $this->buildTwoAddress($delivery_address, $shippingOrgName, $shippingData['country_iso']), + 'line_items' => $line_items, + ]; + + if ($this->shouldIncludeTaxSubtotals()) { + $request_data['tax_subtotals'] = $tax_subtotals; + } return $request_data; } - public function getTwoNewOrderData($id_order, $cart) + /** + * Server-authoritative order intent check used by payment submission. + * Frontend intent remains UX-only; this method decides whether checkout can proceed. + * + * @param Cart $cart + * @param Customer $customer + * @param Currency $currency + * @param Address $address + * @return array{ + * approved:bool, + * status:string, + * message:string, + * timestamp:int, + * http_status:int + * } + */ + public function checkTwoOrderIntentApprovalAtPayment($cart, $customer, $currency, $address) + { + $result = array( + 'approved' => false, + 'status' => 'provider_error', + 'message' => $this->l('Unable to process your order with Two payment.'), + 'timestamp' => time(), + 'http_status' => 0, + ); + + try { + if ($this->shouldRunStrictOrderIntentParityAtPayment()) { + // Authoritative payment-submit intent check must fail-closed on reconciliation drift. + $payload = $this->buildTwoIntentOrderData($cart, $customer, $currency, $address, true); + } else { + $payload = $this->getTwoIntentOrderData($cart, $customer, $currency, $address); + } + } catch (Exception $e) { + $exceptionMessage = (string)$e->getMessage(); + if (stripos($exceptionMessage, 'reconcile') !== false) { + $result['status'] = 'reconciliation_mismatch'; + $result['message'] = $this->l('Unable to process your order with Two payment. Please review your cart and try again.'); + } else { + $result['status'] = 'payload_error'; + } + PrestaShopLogger::addLog( + 'TwoPayment: Backend order intent payload build failed at payment submit - ' . $e->getMessage(), + 3 + ); + return $result; + } + + $response = $this->setTwoPaymentRequest('/v1/order_intent', $payload, 'POST'); + $http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; + $result['http_status'] = $http_status; + + $response_summary = $this->buildTwoApiResponseLogSummary($response); + PrestaShopLogger::addLog( + 'TwoPayment: Backend order intent response summary at payment submit - ' . json_encode($response_summary), + ($http_status >= self::HTTP_STATUS_BAD_REQUEST || $http_status === 0) ? 2 : 1 + ); + + if ($http_status >= self::HTTP_STATUS_OK && $http_status < self::HTTP_STATUS_BAD_REQUEST) { + if (array_key_exists('approved', $response)) { + $approved = (bool)$response['approved']; + $result['approved'] = $approved; + $result['status'] = $approved ? 'approved' : 'declined'; + $result['message'] = $approved + ? '' + : $this->l('Your order could not be approved by Two payment. Please choose another payment method or contact support.'); + + if (!$approved) { + $provider_message = ''; + if (isset($response['message']) && is_string($response['message'])) { + $provider_message = trim($response['message']); + } elseif (isset($response['data']) && is_array($response['data']) && isset($response['data']['message']) && is_string($response['data']['message'])) { + $provider_message = trim($response['data']['message']); + } + + if (!Tools::isEmpty($provider_message)) { + $result['message'] = $provider_message; + } + } + + return $result; + } + + $result['status'] = 'invalid_response'; + $result['message'] = $this->l('Unable to process your order with Two payment.'); + return $result; + } + + if ($http_status === 0) { + $result['status'] = 'provider_unavailable'; + $result['message'] = $this->l('Connection error with payment provider. Please try again.'); + return $result; + } + + if ($http_status >= self::HTTP_STATUS_SERVER_ERROR) { + $result['status'] = 'provider_unavailable'; + $result['message'] = $this->l('Payment provider temporarily unavailable. Please try again later.'); + return $result; + } + + $two_error_message = $this->getTwoErrorMessage($response); + $result['status'] = 'provider_error'; + if (!Tools::isEmpty($two_error_message)) { + $result['message'] = $two_error_message; + } + + return $result; + } + + /** + * Extension hook for tests: keep strict parity enabled in production. + * + * @return bool + */ + protected function shouldRunStrictOrderIntentParityAtPayment() + { + return true; + } + + /** + * Create a local PrestaShop order after provider verification with race-safe recovery. + * + * @param Cart $cart + * @param Customer $customer + * @param int $initial_status + * @param float $provider_gross_amount + * @return array{success:bool,id_order:int,recovered_existing:bool,error:string} + */ + public function createTwoLocalOrderAfterProviderVerification($cart, $customer, $initial_status, $provider_gross_amount) + { + $result = array( + 'success' => false, + 'id_order' => 0, + 'recovered_existing' => false, + 'error' => '', + ); + + if (!Validate::isLoadedObject($cart)) { + $result['error'] = 'cart_invalid'; + return $result; + } + + $currency = new Currency((int)$cart->id_currency); + if (!Validate::isLoadedObject($currency)) { + $result['error'] = 'currency_invalid'; + return $result; + } + + try { + $this->validateOrder( + (int)$cart->id, + (int)$initial_status, + (float)$provider_gross_amount, + $this->displayName, + null, + array(), + (int)$currency->id, + false, + $customer->secure_key + ); + $createdOrderId = (int)$this->currentOrder; + if ($createdOrderId > 0) { + $result['success'] = true; + $result['id_order'] = $createdOrderId; + return $result; + } + } catch (Exception $e) { + $result['error'] = (string)$e->getMessage(); + PrestaShopLogger::addLog( + 'TwoPayment: validateOrder exception after provider verification for cart ' . (int)$cart->id . + ' - ' . $result['error'], + 3 + ); + } + + // Recovery path for idempotent callback retries/races where order was already created. + $existingOrderId = (int)$this->getTwoOrderIdByCart((int)$cart->id); + if ($existingOrderId > 0) { + $result['success'] = true; + $result['id_order'] = $existingOrderId; + $result['recovered_existing'] = true; + return $result; + } + + return $result; + } + + /** + * Best-effort provider order cancellation helper. + * + * @param string $two_order_id + * @param string $context_label + * @return bool + */ + public function cancelTwoOrderBestEffort($two_order_id, $context_label = '') + { + $two_order_id = trim((string)$two_order_id); + if (Tools::isEmpty($two_order_id)) { + return false; + } + + $response = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/cancel', [], 'POST'); + $http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; + $success = ($http_status > 0 && $http_status < self::HTTP_STATUS_BAD_REQUEST); + + PrestaShopLogger::addLog( + 'TwoPayment: Provider order cancel ' . ($success ? 'succeeded' : 'failed') . + ' for Two order ' . $two_order_id . + (!Tools::isEmpty($context_label) ? ' (' . $context_label . ')' : '') . + ', HTTP ' . $http_status, + $success ? 1 : 2 + ); + + return $success; + } + + /** + * Extract provider gross amount for callback-time validateOrder amount. + * Accepts root or nested response payloads and returns null when unavailable/invalid. + * + * @param mixed $order_response + * @return float|null + */ + public function extractTwoProviderGrossAmountForValidation($order_response) + { + if (!is_array($order_response)) { + return null; + } + + $payload = $order_response; + if ( + (!isset($payload['gross_amount']) || Tools::isEmpty($payload['gross_amount'])) && + isset($order_response['data']) && + is_array($order_response['data']) + ) { + $payload = $order_response['data']; + } + + if (!isset($payload['gross_amount']) || Tools::isEmpty($payload['gross_amount'])) { + return null; + } + + if (!is_scalar($payload['gross_amount'])) { + return null; + } + + $gross_amount = (float)$payload['gross_amount']; + if (!is_finite($gross_amount) || $gross_amount < 0) { + return null; + } + + return round($gross_amount, 2); + } + + public function getTwoNewOrderData($merchant_order_id, $cart, $merchant_urls = null) { // Validate cart has products before building order data if (!Validate::isLoadedObject($cart) || $cart->nbProducts() <= 0) { - PrestaShopLogger::addLog('TwoPayment: Cannot build order data - cart is empty or invalid (Order ID: ' . $id_order . ')', 3); + PrestaShopLogger::addLog('TwoPayment: Cannot build order data - cart is empty or invalid (Merchant order ID: ' . $merchant_order_id . ')', 3); throw new Exception('Cart is empty or invalid'); } @@ -1743,27 +2837,13 @@ public function getTwoNewOrderData($id_order, $cart) } } - // Get line items (using PrestaShop's native values) - $line_items = $this->getTwoProductItems($cart); - - // Validate we have line items - if (empty($line_items)) { - PrestaShopLogger::addLog('TwoPayment: Cannot build order data - no valid line items (Order ID: ' . $id_order . ')', 3); - throw new Exception('No valid line items in cart'); - } - - // Calculate tax subtotals from line items first - $tax_subtotals = $this->getTwoTaxSubtotals($line_items); - - // Two API requires gross_amount = sum(tax_subtotals) - // Calculate totals from tax_subtotals to ensure exact match - $totals = $this->calculateOrderTotalsFromTaxSubtotals($tax_subtotals); - $final_net = $totals['net']; - $final_tax = $totals['tax']; - $final_gross = $totals['gross']; - - // Get discount amount from PrestaShop - $final_discount = abs((float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS)); + $pricingData = $this->buildTwoOrderPricingData($cart, 'order data (merchant_order_id=' . $merchant_order_id . ')'); + $line_items = $pricingData['line_items']; + $tax_subtotals = $pricingData['tax_subtotals']; + $final_net = $pricingData['net_amount']; + $final_tax = $pricingData['tax_amount']; + $final_gross = $pricingData['gross_amount']; + $final_discount = $pricingData['discount_amount']; // Get company data with fallback chain (reused helper method) $buyerData = $this->getCompanyDataWithFallbacks($invoice_address); @@ -1771,6 +2851,18 @@ public function getTwoNewOrderData($id_order, $cart) $buyerCompanyName = $buyerData['company_name']; $shippingOrgName = !empty($shippingData['company_name']) ? $shippingData['company_name'] : $buyerCompanyName; + if (!is_array($merchant_urls)) { + // Backward compatibility: legacy flow where merchant order id was the local PrestaShop id_order + $merchant_urls = [ + 'merchant_confirmation_url' => $this->context->link->getModuleLink($this->name, 'confirmation', ['id_order' => $merchant_order_id], true), + 'merchant_cancel_order_url' => $this->context->link->getModuleLink($this->name, 'cancel', ['id_order' => $merchant_order_id], true), + 'merchant_edit_order_url' => '', + 'merchant_order_verification_failed_url' => '', + 'merchant_invoice_url' => '', + 'merchant_shipping_document_url' => '' + ]; + } + $request_data = [ 'gross_amount' => (string)($this->getTwoRoundAmount($final_gross)), 'net_amount' => (string)($this->getTwoRoundAmount($final_net)), @@ -1779,8 +2871,6 @@ public function getTwoNewOrderData($id_order, $cart) 'discount_rate' => '0', 'invoice_type' => 'FUNDED_INVOICE', // Default product type 'tax_amount' => (string)($this->getTwoRoundAmount($final_tax)), - 'tax_rate' => (string)($cart->getAverageProductsTaxRate()), - 'tax_subtotals' => $tax_subtotals, 'buyer' => [ 'company' => [ 'company_name' => $buyerCompanyName, @@ -1792,22 +2882,15 @@ public function getTwoNewOrderData($id_order, $cart) 'email' => $customer->email, 'first_name' => $customer->firstname, 'last_name' => $customer->lastname, - 'phone_number' => $invoice_address->phone, + 'phone_number' => $this->getPhoneWithFallback($invoice_address), ], ], - 'buyer_department' => $invoice_address->department, - 'buyer_project' => $invoice_address->project, + 'buyer_department' => property_exists($invoice_address, 'department') ? (string)$invoice_address->department : '', + 'buyer_project' => property_exists($invoice_address, 'project') ? (string)$invoice_address->project : '', 'merchant_additional_info' => '', - 'merchant_order_id' => (string)($id_order), + 'merchant_order_id' => (string)$merchant_order_id, 'merchant_reference' => (string)($order_reference), - 'merchant_urls' => [ - 'merchant_confirmation_url' => $this->context->link->getModuleLink($this->name, 'confirmation', ['id_order' => $id_order], true), - 'merchant_cancel_order_url' => $this->context->link->getModuleLink($this->name, 'cancel', ['id_order' => $id_order], true), - 'merchant_edit_order_url' => '', - 'merchant_order_verification_failed_url' => '', - 'merchant_invoice_url' => '', - 'merchant_shipping_document_url' => '' - ], + 'merchant_urls' => $merchant_urls, 'billing_address' => $this->buildTwoAddress($invoice_address, $buyerCompanyName, $buyerData['country_iso']), 'shipping_address' => $this->buildTwoAddress($delivery_address, $shippingOrgName, $shippingData['country_iso']), 'shipping_details' => [ @@ -1818,13 +2901,14 @@ public function getTwoNewOrderData($id_order, $cart) 'recurring' => false, 'order_note' => '', 'line_items' => $line_items, - 'terms' => [ - 'type' => 'NET_TERMS', - 'duration_days' => $this->getSelectedPaymentTerm() - ], + 'terms' => $this->buildTermsPayload(), ]; - PrestaShopLogger::addLog('TwoPayment: Order creation with terms - duration_days: ' . $request_data['terms']['duration_days'], 1); + if ($this->shouldIncludeTaxSubtotals()) { + $request_data['tax_subtotals'] = $tax_subtotals; + } + + PrestaShopLogger::addLog('TwoPayment: Order creation with terms - ' . json_encode($request_data['terms']), 1); return $request_data; } @@ -1858,26 +2942,13 @@ public function getTwoUpdateOrderData($order, $orderpaymentdata) } } - // Get line items (using PrestaShop's native values) - $line_items = $this->getTwoProductItems($cart); - - // Validate we have line items - if (empty($line_items)) { - PrestaShopLogger::addLog('TwoPayment: Cannot build update order data - no valid line items (Order ID: ' . $order->id . ')', 3); - throw new Exception('No valid line items in cart'); - } - - // Calculate tax subtotals from line items first - $tax_subtotals = $this->getTwoTaxSubtotals($line_items); - - // Calculate totals from tax_subtotals to ensure exact match - $totals = $this->calculateOrderTotalsFromTaxSubtotals($tax_subtotals); - $final_net = $totals['net']; - $final_tax = $totals['tax']; - $final_gross = $totals['gross']; - - // Get discount amount from PrestaShop - $final_discount = abs((float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS)); + $pricingData = $this->buildTwoOrderPricingData($cart, 'update order data (order_id=' . $order->id . ')'); + $line_items = $pricingData['line_items']; + $tax_subtotals = $pricingData['tax_subtotals']; + $final_net = $pricingData['net_amount']; + $final_tax = $pricingData['tax_amount']; + $final_gross = $pricingData['gross_amount']; + $final_discount = $pricingData['discount_amount']; // Get company data with fallback chain (reused helper method) $buyerData = $this->getCompanyDataWithFallbacks($invoice_address); @@ -1893,8 +2964,6 @@ public function getTwoUpdateOrderData($order, $orderpaymentdata) 'discount_rate' => '0', 'invoice_type' => 'FUNDED_INVOICE', // Default product type 'tax_amount' => (string)($this->getTwoRoundAmount($final_tax)), - 'tax_rate' => (string)($cart->getAverageProductsTaxRate()), - 'tax_subtotals' => $tax_subtotals, 'buyer' => [ 'company' => [ 'company_name' => $buyerCompanyName, @@ -1906,12 +2975,13 @@ public function getTwoUpdateOrderData($order, $orderpaymentdata) 'email' => $customer->email, 'first_name' => $customer->firstname, 'last_name' => $customer->lastname, - 'phone_number' => $invoice_address->phone, + 'phone_number' => $this->getPhoneWithFallback($invoice_address), ], ], - 'buyer_department' => $invoice_address->department, - 'buyer_project' => $invoice_address->project, + 'buyer_department' => property_exists($invoice_address, 'department') ? (string)$invoice_address->department : '', + 'buyer_project' => property_exists($invoice_address, 'project') ? (string)$invoice_address->project : '', 'merchant_additional_info' => '', + 'merchant_order_id' => (string)$order->id, 'merchant_reference' => (string)($orderpaymentdata['two_order_reference']), 'billing_address' => $this->buildTwoAddress($invoice_address, $buyerCompanyName, $buyerData['country_iso']), 'shipping_address' => $this->buildTwoAddress($delivery_address, $shippingOrgName, $shippingData['country_iso']), @@ -1925,7 +2995,11 @@ public function getTwoUpdateOrderData($order, $orderpaymentdata) 'line_items' => $line_items, ]; - return $request_data; + if ($this->shouldIncludeTaxSubtotals()) { + $request_data['tax_subtotals'] = $tax_subtotals; + } + + return $request_data; } public function getTwoNewRefundData($order, $two_order_snapshot = null) @@ -1985,40 +3059,119 @@ public function getTwoProductItems($cart) $items = []; $carrier = new Carrier($cart->id_carrier, $cart->id_lang); $line_items = $cart->getProducts(true); + $use_spanish_rate_policy = $this->shouldApplyTwoSpanishTaxRatePolicy($cart); // Validate cart has products if (empty($line_items)) { PrestaShopLogger::addLog('TwoPayment: Cart is empty, cannot build line items', 3); return $items; // Return empty array (caller should handle empty cart) } + $known_product_rate_candidates = $this->collectTwoKnownTaxRatesFromConfiguredProductRates($line_items); foreach ($line_items as $line_item) { $categories = Product::getProductCategoriesFull($line_item['id_product'], $cart->id_lang); $image = Image::getCover($line_item['id_product']); $imagePath = $this->context->link->getImageLink($line_item['link_rewrite'], $image['id_image'], ImageType::getFormattedName('home')); - // Use PrestaShop's calculated values directly (trust PrestaShop's calculations) - $net_amount_prestashop = (float)$line_item['total']; // PrestaShop's net total (source of truth) - $gross_amount_prestashop = (float)$line_item['total_wt']; // PrestaShop's gross total + // Use PrestaShop monetary amounts as canonical values for payload totals. + $net_amount_prestashop = round((float)$line_item['total'], 2); + $gross_amount_prestashop = round((float)$line_item['total_wt'], 2); + $tax_amount_prestashop = round($gross_amount_prestashop - $net_amount_prestashop, 2); $quantity = (int)$line_item['cart_quantity']; - $tax_rate = (float)$line_item['rate'] / 100; // Convert percentage to decimal - + // CRITICAL: Validate quantity to prevent division by zero if ($quantity <= 0) { PrestaShopLogger::addLog('TwoPayment: Invalid quantity (0 or negative) for product ' . $line_item['id_product'], 3); continue; // Skip invalid line items } + + $ecotax_service_line = null; + $ecotax_breakdown = $this->extractTwoEcotaxLineBreakdown( + $line_item, + $quantity, + $net_amount_prestashop, + $gross_amount_prestashop + ); + if (!empty($ecotax_breakdown['enabled'])) { + $net_amount_prestashop = (float)$ecotax_breakdown['product_net']; + $gross_amount_prestashop = (float)$ecotax_breakdown['product_gross']; + $tax_amount_prestashop = round($gross_amount_prestashop - $net_amount_prestashop, 2); + + if (isset($line_item['price']) && is_numeric($line_item['price'])) { + $line_item['price'] = max(0, (float)$line_item['price'] - (float)$ecotax_breakdown['unit_net']); + } + } + + // DIAGNOSTIC LOGGING: Log tax data for debugging store-specific issues + // Only log when debug mode is enabled to avoid excessive log entries in production + if (Configuration::get('PS_TWO_DEBUG_MODE')) { + $calculated_rate_for_log = ($net_amount_prestashop > 0) + ? round((($gross_amount_prestashop - $net_amount_prestashop) / $net_amount_prestashop) * 100, 2) + : 0; + PrestaShopLogger::addLog( + 'TwoPayment: Product tax debug - ID: ' . $line_item['id_product'] . + ' | rate field: ' . (isset($line_item['rate']) ? $line_item['rate'] : 'NULL') . + ' | total (net): ' . $net_amount_prestashop . + ' | total_wt (gross): ' . $gross_amount_prestashop . + ' | calculated rate: ' . $calculated_rate_for_log . '%', + 1 // Info level + ); + } + + // Derive the effective tax rate from applied PrestaShop amounts first. + // Keep configured rate only when it is very close (normal variance). + $rate_from_field_percent = isset($line_item['rate']) ? (float)$line_item['rate'] : 0; + $rate_from_field_decimal = $rate_from_field_percent / 100; + $rate_from_amounts_decimal = 0.0; + + if ($net_amount_prestashop > 0) { + $rate_from_amounts_decimal = $tax_amount_prestashop / $net_amount_prestashop; + if ($rate_from_amounts_decimal < 0) { + $rate_from_amounts_decimal = 0.0; + } + } + + $tax_rate = 0.0; + if ($net_amount_prestashop > 0) { + $rate_difference = abs($rate_from_field_decimal - $rate_from_amounts_decimal); + if ($rate_from_field_percent > 0 && $rate_difference <= self::TAX_RATE_VARIANCE_TOLERANCE) { + $tax_rate = $rate_from_field_decimal; + } else { + $tax_rate = $rate_from_amounts_decimal; + if ($rate_from_field_percent > 0 && Configuration::get('PS_TWO_DEBUG_MODE')) { + PrestaShopLogger::addLog( + 'TwoPayment: Tax rate variance - field: ' . $rate_from_field_percent . '%, amounts: ' . + round($rate_from_amounts_decimal * 100, 2) . '% | Product: ' . $line_item['id_product'] . + ' | Using applied rate from amounts', + 1 + ); + } + } + } elseif ($rate_from_field_percent > 0) { + $tax_rate = $rate_from_field_decimal; + } + + $tax_rate = $this->normalizeTwoTaxRateToPercentPrecision($tax_rate); + $product_known_context_rates = $known_product_rate_candidates; + if ($rate_from_field_percent > 0) { + $product_known_context_rates[] = $this->normalizeTwoTaxRateToPercentPrecision($rate_from_field_decimal); + } + $snapped_product_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($tax_rate, $product_known_context_rates) + ); + if ( + abs($tax_amount_prestashop - ($net_amount_prestashop * $snapped_product_rate)) <= self::TAX_FORMULA_TOLERANCE + ) { + $tax_rate = $snapped_product_rate; + } - // Use PrestaShop's unit price if available, otherwise calculate from total - // PrestaShop's 'price' field is the unit price (net, without tax) + // Use PrestaShop unit price when available; otherwise derive from net total. $unit_price_net_prestashop = isset($line_item['price']) ? (float)$line_item['price'] : null; if ($unit_price_net_prestashop !== null) { - // Use PrestaShop's unit price directly (most accurate) $unit_price_net = round($unit_price_net_prestashop, 2); // Calculate discount from PrestaShop's values - // Expected total without discount: quantity * unit_price $expected_total = $quantity * $unit_price_net; $discount_amount = round($expected_total - $net_amount_prestashop, 2); @@ -2028,11 +3181,8 @@ public function getTwoProductItems($cart) $discount_amount = 0; } - // Use PrestaShop's net_amount directly (it's the source of truth) $net_amount = $net_amount_prestashop; } else { - // Fallback: derive unit_price from net_amount (if PrestaShop doesn't provide price) - // This happens when PrestaShop's price field is not available $discount_amount = isset($line_item['reduction']) ? (float)$line_item['reduction'] : 0; // Ensure discount is not negative @@ -2040,37 +3190,30 @@ public function getTwoProductItems($cart) $discount_amount = 0; } - // Two API requires exact formula: net_amount = (quantity * unit_price) - discount_amount - // Derive unit_price from net_amount to ensure formula compliance $unit_price_net = ($net_amount_prestashop + $discount_amount) / $quantity; $unit_price_net = round($unit_price_net, 2); - // Recalculate net_amount with rounded unit_price to ensure exact formula match $net_amount = ($quantity * $unit_price_net) - $discount_amount; $net_amount = round($net_amount, 2); } + + $tax_amount = $tax_amount_prestashop; + $gross_amount = $gross_amount_prestashop; - // Calculate tax using Two's formula: tax_amount = net_amount * tax_rate - $tax_amount = round($net_amount * $tax_rate, 2); - - // Use PrestaShop's gross_amount if it matches our calculation (within rounding tolerance) - // Otherwise use calculated gross_amount to satisfy Two API formula - $calculated_gross = $net_amount + $tax_amount; - $gross_diff = abs($gross_amount_prestashop - $calculated_gross); - $gross_tolerance = self::GROSS_AMOUNT_TOLERANCE; - - if ($gross_diff <= $gross_tolerance) { - // PrestaShop's gross is very close, use it (matches what customer sees) - $gross_amount = $gross_amount_prestashop; - } else { - // Use calculated gross to satisfy Two API formula - // Log when tolerance is exceeded for investigation - PrestaShopLogger::addLog( - 'TwoPayment: Gross amount difference exceeds tolerance for product ' . $line_item['id_product'] . - ' - PrestaShop: ' . $gross_amount_prestashop . ', Calculated: ' . $calculated_gross . ', Diff: ' . $gross_diff, - 2 + // Calculate actual tax rate percentage for display (tax_class_name) + $tax_rate_percent_display = round($tax_rate * 100, 2); + $barcodes = array(); + if (!empty($line_item['ean13'])) { + $barcodes[] = array( + 'type' => 'SKU', + 'value' => $line_item['ean13'], + ); + } + if (!empty($line_item['upc'])) { + $barcodes[] = array( + 'type' => 'UPC', + 'value' => $line_item['upc'], ); - $gross_amount = $calculated_gross; } $product = [ @@ -2080,8 +3223,8 @@ public function getTwoProductItems($cart) 'net_amount' => (string)$this->getTwoRoundAmount($net_amount), 'discount_amount' => (string)$this->getTwoRoundAmount($discount_amount), 'tax_amount' => (string)$this->getTwoRoundAmount($tax_amount), - 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($line_item['rate']) . '%', - 'tax_rate' => (string)$this->getTwoRoundAmount($tax_rate), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($tax_rate_percent_display) . '%', + 'tax_rate' => $this->formatTwoTaxRate($tax_rate), 'unit_price' => (string)$this->getTwoRoundAmount($unit_price_net), 'quantity' => $quantity, 'quantity_unit' => 'pcs', @@ -2090,16 +3233,7 @@ public function getTwoProductItems($cart) 'type' => 'PHYSICAL', 'details' => [ 'brand' => $line_item['manufacturer_name'], - 'barcodes' => [ - [ - 'type' => 'SKU', - 'value' => $line_item['ean13'] - ], - [ - 'type' => 'UPC', - 'value' => $line_item['upc'] - ], - ], + 'barcodes' => $barcodes, ], ]; $product['details']['categories'] = []; @@ -2110,34 +3244,73 @@ public function getTwoProductItems($cart) } $items[] = $product; + + if (!empty($ecotax_breakdown['enabled'])) { + $ecotax_rate = (float)$ecotax_breakdown['rate']; + $ecotax_rate_percent = round($ecotax_rate * 100, 2); + $ecotax_service_line = [ + 'name' => $line_item['name'] . ' - ' . $this->l('Ecotax'), + 'description' => Tools::substr(strip_tags($this->l('Environmental tax (ecotax)')), 0, 255), + 'gross_amount' => (string)$this->getTwoRoundAmount($ecotax_breakdown['gross']), + 'net_amount' => (string)$this->getTwoRoundAmount($ecotax_breakdown['net']), + 'discount_amount' => '0.00', + 'tax_amount' => (string)$this->getTwoRoundAmount($ecotax_breakdown['tax']), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($ecotax_rate_percent) . '%', + 'tax_rate' => $this->formatTwoTaxRate($ecotax_rate), + 'unit_price' => (string)$this->getTwoRoundAmount($ecotax_breakdown['net']), + 'quantity' => 1, + 'quantity_unit' => 'item', + 'image_url' => $imagePath, + 'product_page_url' => $this->context->link->getProductLink($line_item['id_product']), + 'type' => 'SERVICE', + ]; + $items[] = $ecotax_service_line; + } } // Add shipping as a line item if applicable - if (Validate::isLoadedObject($carrier) && $cart->getOrderTotal(true, Cart::ONLY_SHIPPING) > 0) { - // Use PrestaShop's shipping totals (source of truth) - $shipping_net = round((float)$cart->getOrderTotal(false, Cart::ONLY_SHIPPING), 2); - $shipping_gross_prestashop = (float)$cart->getOrderTotal(true, Cart::ONLY_SHIPPING); - - // Calculate tax rate from PrestaShop values (derive from PrestaShop's calculated tax) - $shipping_tax_prestashop = $shipping_gross_prestashop - $shipping_net; - $shipping_tax_rate_percent = 0; - $shipping_tax_rate_decimal = 0; - if ($shipping_net > 0) { - // CRITICAL: Calculate percentage first, then round, then convert to decimal - // This preserves precision for non-standard tax rates (e.g., 20.5%) - $shipping_tax_rate_percent = ($shipping_tax_prestashop / $shipping_net) * 100; - $shipping_tax_rate_percent = round($shipping_tax_rate_percent, 2); // Round percentage to 2 decimals - $shipping_tax_rate_decimal = $shipping_tax_rate_percent / 100; // Convert to decimal - } - - // Two API requires exact formula: tax_amount = net_amount * tax_rate - // Recalculate tax_amount using rounded values to ensure formula compliance - $shipping_tax_amount = round($shipping_net * $shipping_tax_rate_decimal, 2); - - // Calculate gross: gross_amount = net_amount + tax_amount (Two API formula) - $shipping_gross = $shipping_net + $shipping_tax_amount; - - // For shipping: quantity = 1, discount = 0, so unit_price = net_amount + // BEST PRACTICE: Use getPackageShippingCost() to get actual carrier cost BEFORE free shipping rules + // This fixes the issue where getOrderTotal(ONLY_SHIPPING) returns 0 when free shipping cart rules are active + $shipping_cost_with_tax = 0; + $shipping_cost_without_tax = 0; + + if (Validate::isLoadedObject($carrier)) { + // Method 1: Get package shipping cost directly from carrier (ignores free shipping rules) + // Parameters: id_carrier, use_tax, country, product_list, id_zone + $shipping_cost_with_tax = $cart->getPackageShippingCost((int)$cart->id_carrier, true, null, null, null); + $shipping_cost_without_tax = $cart->getPackageShippingCost((int)$cart->id_carrier, false, null, null, null); + + // Fallback: If getPackageShippingCost returns 0 or false, try getOrderTotal + // This handles edge cases where carrier pricing might be complex + if ($shipping_cost_with_tax <= 0) { + $shipping_cost_with_tax = (float)$cart->getOrderTotal(true, Cart::ONLY_SHIPPING); + $shipping_cost_without_tax = (float)$cart->getOrderTotal(false, Cart::ONLY_SHIPPING); + } + } + + if (Validate::isLoadedObject($carrier) && $shipping_cost_with_tax > 0) { + // Keep shipping monetary values canonical to PrestaShop totals. + $shipping_net = round((float)$shipping_cost_without_tax, 2); + $shipping_gross = round((float)$shipping_cost_with_tax, 2); + $shipping_tax_amount = round($shipping_gross - $shipping_net, 2); + $shipping_tax_rate_decimal = $shipping_net > 0 + ? max(0, $shipping_tax_amount / $shipping_net) + : 0.0; + $shipping_tax_rate_decimal = $this->normalizeTwoTaxRateToPercentPrecision($shipping_tax_rate_decimal); + $shipping_known_context_rates = $this->collectTwoKnownTaxRatesFromPositiveItems($items); + $carrier_configured_rate = $this->getTwoCarrierConfiguredTaxRateDecimal($carrier, $cart); + if ($carrier_configured_rate > 0) { + $shipping_known_context_rates[] = $carrier_configured_rate; + } + $snapped_shipping_tax_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($shipping_tax_rate_decimal, $shipping_known_context_rates) + ); + if ( + abs($shipping_tax_amount - ($shipping_net * $snapped_shipping_tax_rate)) <= self::TAX_FORMULA_TOLERANCE + ) { + $shipping_tax_rate_decimal = $snapped_shipping_tax_rate; + } + $shipping_tax_rate_percent = round($shipping_tax_rate_decimal * 100, 2); $shipping_unit_price = $shipping_net; $shipping_name = $carrier->name ? $carrier->name : $this->l('Shipping'); @@ -2165,7 +3338,7 @@ public function getTwoProductItems($cart) 'discount_amount' => '0.00', 'tax_amount' => (string)$this->getTwoRoundAmount($shipping_tax_amount), 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($shipping_tax_rate_percent) . '%', - 'tax_rate' => (string)$this->getTwoRoundAmount($shipping_tax_rate_decimal), + 'tax_rate' => $this->formatTwoTaxRate($shipping_tax_rate_decimal), 'unit_price' => (string)$this->getTwoRoundAmount($shipping_unit_price), 'quantity' => 1, 'quantity_unit' => 'pcs', @@ -2177,932 +3350,3555 @@ public function getTwoProductItems($cart) $items[] = $shipping_line; } - // Add cart-level discounts as line item if applicable - // Note: PrestaShop returns discounts as positive values (the amount discounted) - $discount_gross_prestashop = (float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS); - if ($discount_gross_prestashop > 0) { - // Use PrestaShop's discount totals (source of truth) - $discount_net_total = round((float)$cart->getOrderTotal(false, Cart::ONLY_DISCOUNTS), 2); - $discount_tax_prestashop = $discount_gross_prestashop - $discount_net_total; - - // Calculate tax rate from PrestaShop values (handle edge case where net_total might be 0) - $discount_tax_rate_percent = 0; - $discount_tax_rate_decimal = 0; - if ($discount_net_total > 0) { - // Calculate percentage first, then round, then convert to decimal - // This preserves precision for non-standard tax rates (e.g., 20.5%) - $discount_tax_rate_percent = ($discount_tax_prestashop / $discount_net_total) * 100; - $discount_tax_rate_percent = round($discount_tax_rate_percent, 2); // Round percentage to 2 decimals - $discount_tax_rate_decimal = $discount_tax_rate_percent / 100; // Convert to decimal - } elseif ($discount_tax_prestashop > 0) { - // Edge case: net_total = 0 but tax exists (shouldn't happen, but handle gracefully) - // Cannot calculate percentage, default to 0 (tax_amount will be 0 anyway) - PrestaShopLogger::addLog('TwoPayment: Discount net_total is 0 but tax exists, defaulting tax rate to 0', 2); - } - - // Two API requires exact formula: tax_amount = net_amount * tax_rate - // Recalculate tax_amount using rounded values to ensure formula compliance - // Note: net_amount is negative (discount), so tax_amount will also be negative - $discount_tax_amount = round($discount_net_total * $discount_tax_rate_decimal, 2); - - // Calculate gross: gross_amount = net_amount + tax_amount (Two API formula) - $discount_gross_total = $discount_net_total + $discount_tax_amount; - - // For discount: quantity = 1, discount = 0, so unit_price = net_amount (negative) - $discount_unit_price = $discount_net_total; - - $cart_rules = $cart->getCartRules(); - $discount_name = $this->l('Discount'); - $discount_description = $this->l('Order discount'); - - if (!empty($cart_rules)) { - $primary_rule = reset($cart_rules); - $discount_name = $primary_rule['name']; - - $discount_parts = []; - foreach ($cart_rules as $rule) { - $rule_desc = $rule['name']; - if ($rule['code']) { - $rule_desc .= ' (' . $rule['code'] . ')'; - } - if ($rule['value']) { - if ($rule['reduction_percent'] > 0) { - $rule_desc .= ' - ' . $rule['reduction_percent'] . '%'; - } elseif ($rule['reduction_amount'] > 0) { - $rule_desc .= ' - ' . Tools::displayPrice($rule['reduction_amount']); - } - } - $discount_parts[] = $rule_desc; - } - - $discount_description = implode(', ', $discount_parts); - if (strlen($discount_description) > 200) { - $discount_description = $primary_rule['description'] ? - Tools::substr(strip_tags($primary_rule['description']), 0, 200) : - sprintf($this->l('Discount: %s'), $primary_rule['name']); - } - - if (count($cart_rules) > 1) { - $discount_name = sprintf($this->l('%s (+%d more)'), $primary_rule['name'], count($cart_rules) - 1); - } + $wrapping_totals = $this->getTwoGiftWrappingTotals($cart); + if ($wrapping_totals['gross'] > 0) { + $wrapping_rate_decimal = $wrapping_totals['net'] > 0 + ? max(0, $wrapping_totals['tax'] / $wrapping_totals['net']) + : 0.0; + $wrapping_rate_decimal = $this->normalizeTwoTaxRateToPercentPrecision($wrapping_rate_decimal); + $wrapping_known_context_rates = $this->collectTwoKnownTaxRatesFromPositiveItems($items); + $snapped_wrapping_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($wrapping_rate_decimal, $wrapping_known_context_rates) + ); + if ( + abs($wrapping_totals['tax'] - ($wrapping_totals['net'] * $snapped_wrapping_rate)) <= self::TAX_FORMULA_TOLERANCE + ) { + $wrapping_rate_decimal = $snapped_wrapping_rate; } - - $discount_line = [ - 'name' => $discount_name, - 'description' => Tools::substr(strip_tags($discount_description), 0, 255), - 'gross_amount' => (string)$this->getTwoRoundAmount(-$discount_gross_total), - 'net_amount' => (string)$this->getTwoRoundAmount(-$discount_net_total), + $wrapping_rate_percent = round($wrapping_rate_decimal * 100, 2); + $items[] = [ + 'name' => $this->l('Gift wrapping'), + 'description' => Tools::substr(strip_tags($this->l('Gift wrapping for this order')), 0, 255), + 'gross_amount' => (string)$this->getTwoRoundAmount($wrapping_totals['gross']), + 'net_amount' => (string)$this->getTwoRoundAmount($wrapping_totals['net']), 'discount_amount' => '0.00', - 'tax_amount' => (string)$this->getTwoRoundAmount(-$discount_tax_amount), - 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($discount_tax_rate_percent) . '%', - 'tax_rate' => (string)$this->getTwoRoundAmount($discount_tax_rate_decimal), - 'unit_price' => (string)$this->getTwoRoundAmount(-$discount_unit_price), + 'tax_amount' => (string)$this->getTwoRoundAmount($wrapping_totals['tax']), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($wrapping_rate_percent) . '%', + 'tax_rate' => $this->formatTwoTaxRate($wrapping_rate_decimal), + 'unit_price' => (string)$this->getTwoRoundAmount($wrapping_totals['net']), 'quantity' => 1, 'quantity_unit' => 'item', 'image_url' => '', 'product_page_url' => '', - 'type' => 'DIGITAL' + 'type' => 'DIGITAL', ]; + } + + // Add cart-level discounts as one or more lines split by tax context when applicable. + $discount_lines = $this->buildTwoDiscountLinesFromCartTotals($cart, $items); + if (!empty($discount_lines)) { + foreach ($discount_lines as $discount_line) { + $items[] = $discount_line; + } + } - $items[] = $discount_line; + if ($use_spanish_rate_policy) { + $items = $this->applyTwoSpanishCanonicalTaxRateFallbackToItems($items); } return $items; } /** - * Calculate order totals from tax subtotals (Two API requirement) - * Ensures gross_amount = sum(tax_subtotals) for API validation - * - * @param array $tax_subtotals Tax subtotals array from getTwoTaxSubtotals() - * @return array ['net' => float, 'tax' => float, 'gross' => float] + * Resolve gift wrapping totals from cart, if wrapping is enabled. + * + * @param Cart $cart + * @return array{net:float,tax:float,gross:float} */ - private function calculateOrderTotalsFromTaxSubtotals($tax_subtotals) + private function getTwoGiftWrappingTotals($cart) { - $net = 0; - $tax = 0; - foreach ($tax_subtotals as $subtotal) { - $net += (float)$subtotal['taxable_amount']; - $tax += (float)$subtotal['tax_amount']; + if (!defined('Cart::ONLY_WRAPPING')) { + return ['net' => 0.0, 'tax' => 0.0, 'gross' => 0.0]; } - return [ - 'net' => $net, - 'tax' => $tax, - 'gross' => $net + $tax - ]; - } - /** - * Get company name and organization number with fallback chain - * Priority: Address → Cookie → DNI (ES only) - * - * @param Address $address Invoice or delivery address - * @return array ['company_name' => string, 'organization_number' => string, 'country_iso' => string] - */ - private function getCompanyDataWithFallbacks($address) - { - // Validate address object is loaded - if (!Validate::isLoadedObject($address)) { - PrestaShopLogger::addLog('TwoPayment: Invalid address object passed to getCompanyDataWithFallbacks', 3); - throw new Exception('Invalid address object'); - } - - // CRITICAL: Validate country ID and handle false return from Country::getIsoById() - $country_iso = Country::getIsoById($address->id_country); - if (!$country_iso || !is_string($country_iso)) { - PrestaShopLogger::addLog('TwoPayment: Invalid country ID: ' . $address->id_country . ' for address ID: ' . $address->id, 3); - throw new Exception('Invalid country in address'); + $wrapping_gross = round((float)$cart->getOrderTotal(true, Cart::ONLY_WRAPPING), 2); + $wrapping_net = round((float)$cart->getOrderTotal(false, Cart::ONLY_WRAPPING), 2); + if ($wrapping_gross <= 0 && $wrapping_net <= 0) { + return ['net' => 0.0, 'tax' => 0.0, 'gross' => 0.0]; } - - // Company name: Address → Cookie - $company_name = !empty($address->company) - ? $address->company - : (isset($this->context->cookie->two_company_name) - ? trim($this->context->cookie->two_company_name) - : ''); - - // Organization number: Address companyid → Cookie → DNI (ES only) - $org_number = ''; - if (!empty($address->companyid)) { - $org_number = $address->companyid; - } elseif (!empty($this->context->cookie->two_company_id)) { - $org_number = trim($this->context->cookie->two_company_id); - } elseif ($country_iso === 'ES' && !empty($address->dni)) { - $org_number = $address->dni; + + if ($wrapping_gross < $wrapping_net) { + $wrapping_gross = $wrapping_net; } - + return [ - 'company_name' => $company_name, - 'organization_number' => $org_number, - 'country_iso' => $country_iso + 'net' => $wrapping_net, + 'tax' => round($wrapping_gross - $wrapping_net, 2), + 'gross' => $wrapping_gross, ]; } /** - * Build address array for Two API - * - * @param Address $address PrestaShop Address object - * @param string|null $organization_name Company name (may differ from address->company) - * @return array Two API address format + * Build ecotax split data for a product line when ecotax is available. + * PrestaShop can include ecotax in product totals, which can distort product VAT context. + * We model ecotax as a dedicated service line when a safe split is possible. + * + * @param array $line_item + * @param int $quantity + * @param float $line_net + * @param float $line_gross + * @return array{enabled:bool,net:float,tax:float,gross:float,rate:float,product_net:float,product_gross:float,unit_net:float} */ - private function buildTwoAddress($address, $organization_name = null, $country_iso = null) + private function extractTwoEcotaxLineBreakdown($line_item, $quantity, $line_net, $line_gross) { - // Validate address object is loaded - if (!Validate::isLoadedObject($address)) { - PrestaShopLogger::addLog('TwoPayment: Invalid address object passed to buildTwoAddress', 3); - throw new Exception('Invalid address object'); + $quantity = (int)$quantity; + if ($quantity <= 0) { + return ['enabled' => false]; } - - if ($organization_name === null) { - $organization_name = $address->company; + + $ecotax_unit_net = isset($line_item['ecotax']) && is_numeric($line_item['ecotax']) + ? abs((float)$line_item['ecotax']) + : 0.0; + $ecotax_total_net = round($ecotax_unit_net * $quantity, 2); + if (isset($line_item['total_ecotax']) && is_numeric($line_item['total_ecotax'])) { + $ecotax_total_net = round(abs((float)$line_item['total_ecotax']), 2); } - - // Use provided country_iso or fetch it (validate false return) - if ($country_iso === null) { - $country_iso = Country::getIsoById($address->id_country); - if (!$country_iso || !is_string($country_iso)) { - PrestaShopLogger::addLog('TwoPayment: Invalid country ID: ' . $address->id_country . ' for address ID: ' . $address->id, 3); - throw new Exception('Invalid country in address'); - } + if ($ecotax_total_net <= 0) { + return ['enabled' => false]; } - - // Validate street_address is not empty (Two API requirement) - $street_address = trim($address->address1 . (!empty($address->address2) ? ' ' . $address->address2 : '')); - if (empty($street_address)) { - PrestaShopLogger::addLog('TwoPayment: Empty street address for address ID: ' . $address->id, 3); - // Use fallback instead of throwing (allows order to proceed) - $street_address = 'N/A'; + + $ecotax_rate_percent = isset($line_item['ecotax_tax_rate']) && is_numeric($line_item['ecotax_tax_rate']) + ? max(0, (float)$line_item['ecotax_tax_rate']) + : (isset($line_item['rate']) ? max(0, (float)$line_item['rate']) : 0.0); + $ecotax_rate_decimal = round($ecotax_rate_percent / 100, self::TAX_RATE_PRECISION); + $ecotax_total_tax = round($ecotax_total_net * $ecotax_rate_decimal, 2); + $ecotax_total_gross = round($ecotax_total_net + $ecotax_total_tax, 2); + + $product_net = round((float)$line_net - $ecotax_total_net, 2); + $product_gross = round((float)$line_gross - $ecotax_total_gross, 2); + if ($product_net <= 0 || $product_gross < 0 || $product_gross < $product_net) { + return ['enabled' => false]; } - + return [ - 'city' => $address->city, - 'country' => $country_iso, - 'organization_name' => $organization_name, - 'postal_code' => $address->postcode, - 'region' => $address->id_state ? State::getNameById($address->id_state) : '', - 'street_address' => $street_address + 'enabled' => true, + 'net' => $ecotax_total_net, + 'tax' => $ecotax_total_tax, + 'gross' => $ecotax_total_gross, + 'rate' => $ecotax_rate_decimal, + 'product_net' => $product_net, + 'product_gross' => $product_gross, + 'unit_net' => round($ecotax_total_net / $quantity, 6), ]; } /** - * Calculate tax subtotals for Two API compliance - * Groups line items by tax rate and calculates taxable_amount and tax_amount per rate + * Build discount lines from PrestaShop cart totals. + * Splits discount across detected tax contexts to avoid blended synthetic rates. * - * @param array $line_items Array of line items with tax_rate, net_amount, and tax_amount - * @return array Tax subtotals array for Two API + * @param Cart $cart + * @param array $existingItems Positive payload lines already built (products/shipping) + * @return array */ - public function getTwoTaxSubtotals($line_items) + private function buildTwoDiscountLinesFromCartTotals($cart, $existingItems) { - $tax_subtotals = []; - $tax_groups = []; - - // Group line items by tax rate - foreach ($line_items as $item) { - $tax_rate = (string)$item['tax_rate']; - // Round amounts before summing to prevent floating point precision issues - $net_amount = round((float)$item['net_amount'], 2); - $tax_amount = round((float)$item['tax_amount'], 2); - - if (!isset($tax_groups[$tax_rate])) { - $tax_groups[$tax_rate] = [ - 'taxable_amount' => 0, - 'tax_amount' => 0, - 'tax_rate' => $tax_rate - ]; + $discount_gross_total = round((float)$cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS), 2); + if ($discount_gross_total <= 0) { + return []; + } + + $discount_net_total = round((float)$cart->getOrderTotal(false, Cart::ONLY_DISCOUNTS), 2); + $discount_tax_total = round($discount_gross_total - $discount_net_total, 2); + $lines = []; + $remaining_discount_gross = $discount_gross_total; + $remaining_discount_net = $discount_net_total; + + // Prefer cart-rule monetary values when available to keep discount attribution + // aligned with PrestaShop invoice semantics. Fallback to context-based split. + $rule_scope_meta = []; + $rule_scoped_lines = $this->buildTwoDiscountLinesFromCartRules( + $cart, + $discount_net_total, + $discount_gross_total, + $existingItems, + $remaining_discount_net, + $remaining_discount_gross, + $rule_scope_meta + ); + if (!empty($rule_scoped_lines)) { + foreach ($rule_scoped_lines as $rule_scoped_line) { + $lines[] = $rule_scoped_line; + } + if ($remaining_discount_gross < 0) { + $remaining_discount_gross = 0.0; + } + if ($remaining_discount_net < 0) { + $remaining_discount_net = 0.0; + } + if ($remaining_discount_net > $remaining_discount_gross) { + $remaining_discount_net = $remaining_discount_gross; } - - // Round after each addition to prevent precision drift - $tax_groups[$tax_rate]['taxable_amount'] = round($tax_groups[$tax_rate]['taxable_amount'] + $net_amount, 2); - $tax_groups[$tax_rate]['tax_amount'] = round($tax_groups[$tax_rate]['tax_amount'] + $tax_amount, 2); } - - // Convert to Two API format - foreach ($tax_groups as $rate => $group) { - $tax_subtotals[] = [ - 'tax_rate' => (string)($this->getTwoRoundAmount((float)$rate)), // Rate is already in decimal format - 'taxable_amount' => (string)($this->getTwoRoundAmount($group['taxable_amount'])), - 'tax_amount' => (string)($this->getTwoRoundAmount($group['tax_amount'])) + + $fallback_items = $existingItems; + + // Edge-path hardening: if rule-level monetary metadata is incomplete, carve out unresolved + // free-shipping discount against the shipping context first to avoid blended attribution. + $should_attempt_free_shipping_fallback = empty($rule_scoped_lines); + $free_shipping_fallback_cap = null; + if ( + !$should_attempt_free_shipping_fallback && + $remaining_discount_gross > 0 && + isset($rule_scope_meta['incomplete_free_shipping_gross']) && + (float)$rule_scope_meta['incomplete_free_shipping_gross'] > 0 + ) { + $should_attempt_free_shipping_fallback = true; + $free_shipping_fallback_cap = (float)$rule_scope_meta['incomplete_free_shipping_gross']; + } + + if ($should_attempt_free_shipping_fallback) { + $fallback_free_shipping = $this->buildTwoFallbackFreeShippingDiscountLine( + $cart, + $fallback_items, + $remaining_discount_gross, + $remaining_discount_net, + $free_shipping_fallback_cap + ); + if ($fallback_free_shipping !== null) { + $lines[] = $fallback_free_shipping['line']; + $remaining_discount_gross = round($remaining_discount_gross - $fallback_free_shipping['gross'], 2); + $remaining_discount_net = round($remaining_discount_net - $fallback_free_shipping['net'], 2); + if ($remaining_discount_gross < 0) { + $remaining_discount_gross = 0.0; + } + if ($remaining_discount_net < 0) { + $remaining_discount_net = 0.0; + } + $fallback_items = $this->filterTwoShippingFeeItems($fallback_items); + } + } + + if ($remaining_discount_gross <= 0) { + return $lines; + } + + if ($remaining_discount_net > $remaining_discount_gross) { + $remaining_discount_net = $remaining_discount_gross; + } + $remaining_discount_tax = round($remaining_discount_gross - $remaining_discount_net, 2); + + $descriptor = $this->buildTwoDiscountDescriptor($cart); + $contexts = $this->collectDiscountTaxContextsFromItems($fallback_items); + + if (empty($contexts)) { + $contexts = [ + '0' => [ + 'tax_rate' => 0.0, + 'net_weight' => 1.0, + 'tax_weight' => 1.0, + ], ]; } - - // Sort by tax rate for consistency - usort($tax_subtotals, function($a, $b) { - return (float)$a['tax_rate'] <=> (float)$b['tax_rate']; - }); - - return $tax_subtotals; + + $net_weights = []; + $tax_weights = []; + $known_context_rates = []; + foreach ($contexts as $context_key => $context_data) { + $net_weights[$context_key] = (float)$context_data['net_weight']; + $tax_weights[$context_key] = (float)$context_data['tax_weight']; + $known_context_rates[] = (float)$context_data['tax_rate']; + } + + $allocated_nets = $this->allocateTwoAmountByWeights($remaining_discount_net, $net_weights); + $tax_weight_source = array_sum($tax_weights) > 0 ? $tax_weights : $net_weights; + $allocated_taxes = $this->allocateTwoAmountByWeights(max(0, $remaining_discount_tax), $tax_weight_source); + + $is_context_split = count($contexts) > 1; + foreach ($contexts as $context_key => $context_data) { + $line_net = isset($allocated_nets[$context_key]) ? (float)$allocated_nets[$context_key] : 0.0; + $line_tax = isset($allocated_taxes[$context_key]) ? (float)$allocated_taxes[$context_key] : 0.0; + $line_gross = round($line_net + $line_tax, 2); + + if ($line_gross <= 0) { + continue; + } + + $segments = $this->buildTwoCanonicalDiscountRateSegments($line_net, $line_tax, $known_context_rates); + if (empty($segments)) { + $line_rate_raw = $line_net > 0 + ? max(0, $line_tax / $line_net) + : max(0, (float)$context_data['tax_rate']); + $line_rate = $this->normalizeTwoTaxRateToPercentPrecision($line_rate_raw); + $snapped_line_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($line_rate_raw, $known_context_rates) + ); + if (abs($line_tax - ($line_net * $snapped_line_rate)) <= self::TAX_FORMULA_TOLERANCE) { + $line_rate = $snapped_line_rate; + } + $segments = [[ + 'net' => $line_net, + 'tax' => $line_tax, + 'gross' => $line_gross, + 'rate' => $line_rate, + 'precision' => 4, + ]]; + } + + $is_segment_split = count($segments) > 1; + foreach ($segments as $segment) { + $segment_net = round((float)$segment['net'], 2); + $segment_tax = round((float)$segment['tax'], 2); + $segment_gross = round((float)$segment['gross'], 2); + if ($segment_gross <= 0) { + continue; + } + + $segment_rate = max(0, (float)$segment['rate']); + $segment_rate_percent = round($segment_rate * 100, 2); + $line_name = $descriptor['name']; + if ($is_context_split || $is_segment_split) { + $line_name .= ' (' . $this->l('VAT') . ' ' . $this->getTwoRoundAmount($segment_rate_percent) . '%)'; + } + + $line_rate_precision = isset($segment['precision']) ? (int)$segment['precision'] : null; + $line_rate_formatted = $line_rate_precision === null + ? $this->formatTwoTaxRate($segment_rate) + : $this->formatTwoTaxRate($segment_rate, $line_rate_precision); + + $lines[] = [ + 'name' => $line_name, + 'description' => $descriptor['description'], + 'gross_amount' => (string)$this->getTwoRoundAmount(-$segment_gross), + 'net_amount' => (string)$this->getTwoRoundAmount(-$segment_net), + 'discount_amount' => '0.00', + 'tax_amount' => (string)$this->getTwoRoundAmount(-$segment_tax), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($segment_rate_percent) . '%', + 'tax_rate' => $line_rate_formatted, + 'unit_price' => (string)$this->getTwoRoundAmount(-$segment_net), + 'quantity' => 1, + 'quantity_unit' => 'item', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'DIGITAL', + ]; + } + } + + return $lines; } /** - * Validate all line items against Two API formulas (streamlined) - * Only logs critical validation failures + * Build a fallback free-shipping discount line when rule-level monetary metadata is incomplete. + * This keeps shipping discount attribution on shipping VAT context in fallback mode. * - * @param array $line_items Array of line items to validate - * @return bool True if all validations pass, false otherwise + * @param Cart $cart + * @param array $existingItems + * @param float $discountGrossTotal + * @param float $discountNetTotal + * @param float|null $freeShippingGrossOverride Positive unresolved free-shipping gross cap + * @return array|null */ - public function validateTwoLineItems($line_items) + private function buildTwoFallbackFreeShippingDiscountLine( + $cart, + $existingItems, + $discountGrossTotal, + $discountNetTotal, + $freeShippingGrossOverride = null + ) { - $validation_issues = 0; - - foreach ($line_items as $item) { - $net_amount = (float)$item['net_amount']; - $tax_amount = (float)$item['tax_amount']; - $tax_rate = (float)$item['tax_rate']; - $unit_price = (float)$item['unit_price']; - $quantity = (int)$item['quantity']; - $discount_amount = (float)$item['discount_amount']; - - // Critical validation: tax_amount = net_amount * tax_rate (tax_rate is now decimal) - // Allow 0.01 tolerance for rounding differences (2 decimal places = ±0.005 rounding error) - $expected_tax_amount = $net_amount * $tax_rate; - if (abs($tax_amount - $expected_tax_amount) > self::TAX_FORMULA_TOLERANCE) { - PrestaShopLogger::addLog( - 'TwoPayment CRITICAL Tax Formula Error - Item: ' . $item['name'] . - ', Got: ' . $tax_amount . ', Expected: ' . $expected_tax_amount . - ' (diff: ' . abs($tax_amount - $expected_tax_amount) . ')', - 3 - ); - $validation_issues++; + $shipping_line = null; + foreach ($existingItems as $item) { + $line_type = isset($item['type']) ? (string)$item['type'] : ''; + $line_gross = isset($item['gross_amount']) ? round((float)$item['gross_amount'], 2) : 0.0; + if ($line_type === 'SHIPPING_FEE' && $line_gross > 0) { + $shipping_line = $item; + break; } - - // Critical validation: net_amount = (quantity * unit_price) - discount_amount - // Allow 0.05 tolerance for rounding differences (accounts for multiple rounding operations) - $expected_net_amount = ($quantity * $unit_price) - $discount_amount; - if (abs($net_amount - $expected_net_amount) > self::NET_FORMULA_TOLERANCE) { - PrestaShopLogger::addLog( - 'TwoPayment CRITICAL Net Formula Error - Item: ' . $item['name'] . - ', Got: ' . $net_amount . ', Expected: ' . $expected_net_amount . - ' (diff: ' . abs($net_amount - $expected_net_amount) . ')', - 3 - ); - $validation_issues++; + } + if ($shipping_line === null) { + return null; + } + + $free_shipping_rules = []; + $free_shipping_gross = 0.0; + $cart_rules = $cart->getCartRules(); + foreach ($cart_rules as $rule) { + if (empty($rule['free_shipping'])) { + continue; + } + + $rule_gross = $this->extractTwoDiscountRuleGrossAmount($rule); + if ($rule_gross <= 0 && isset($rule['reduction_amount']) && is_numeric($rule['reduction_amount'])) { + $rule_gross = abs((float)$rule['reduction_amount']); } + if ($rule_gross <= 0) { + continue; + } + + $free_shipping_rules[] = $rule; + $free_shipping_gross = round($free_shipping_gross + $rule_gross, 2); } - - return $validation_issues === 0; - } - /** - * Format amount to 2 decimals as string (Two API requirement) - * PrestaShop values are already rounded, this just formats for Two API - * Uses standard PHP number_format - no need for PrestaShop's rounding methods - */ - public function getTwoRoundAmount($amount) - { - return number_format((float)$amount, 2, '.', ''); - } + if ($freeShippingGrossOverride !== null) { + $free_shipping_gross = round(max(0, (float)$freeShippingGrossOverride), 2); + } - public function getTwoCheckoutHostUrl() - { - $environment = Configuration::get('PS_TWO_ENVIRONMENT'); - - if ($environment === 'production') { - return 'https://api.two.inc'; - } else { - // Development environment (default) - return 'https://api.sandbox.two.inc'; + if ($free_shipping_gross <= 0) { + return null; + } + + $shipping_gross = isset($shipping_line['gross_amount']) ? round((float)$shipping_line['gross_amount'], 2) : 0.0; + $shipping_net = isset($shipping_line['net_amount']) ? round((float)$shipping_line['net_amount'], 2) : 0.0; + $shipping_tax = round($shipping_gross - $shipping_net, 2); + if ($shipping_gross <= 0) { + return null; + } + + $alloc_gross = min($free_shipping_gross, max(0, (float)$discountGrossTotal), $shipping_gross); + if ($alloc_gross <= 0) { + return null; + } + + $shipping_ratio = $shipping_gross > 0 ? ($shipping_net / $shipping_gross) : 1.0; + $alloc_net = round($alloc_gross * $shipping_ratio, 2); + $alloc_net = min($alloc_net, round(max(0, (float)$discountNetTotal), 2), $alloc_gross); + $alloc_tax = round($alloc_gross - $alloc_net, 2); + + $shipping_rate = $shipping_net > 0 ? ($shipping_tax / $shipping_net) : 0.0; + if ($shipping_rate < 0) { + $shipping_rate = 0.0; } + $shipping_rate = $this->normalizeTwoTaxRateToPercentPrecision($shipping_rate); + $shipping_known_context_rates = $this->collectTwoKnownTaxRatesFromPositiveItems($existingItems); + $snapped_shipping_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($shipping_rate, $shipping_known_context_rates) + ); + if (abs($shipping_tax - ($shipping_net * $snapped_shipping_rate)) <= self::TAX_FORMULA_TOLERANCE) { + $shipping_rate = $snapped_shipping_rate; + } + $shipping_rate_percent = round($shipping_rate * 100, 2); + $descriptor_rule = !empty($free_shipping_rules) ? reset($free_shipping_rules) : null; + $descriptor = $descriptor_rule !== null + ? $this->buildTwoSingleDiscountDescriptor($descriptor_rule) + : $this->buildTwoDiscountDescriptor($cart); + + return [ + 'gross' => $alloc_gross, + 'net' => $alloc_net, + 'line' => [ + 'name' => $descriptor['name'], + 'description' => $descriptor['description'], + 'gross_amount' => (string)$this->getTwoRoundAmount(-$alloc_gross), + 'net_amount' => (string)$this->getTwoRoundAmount(-$alloc_net), + 'discount_amount' => '0.00', + 'tax_amount' => (string)$this->getTwoRoundAmount(-$alloc_tax), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($shipping_rate_percent) . '%', + 'tax_rate' => $this->formatTwoTaxRate($shipping_rate), + 'unit_price' => (string)$this->getTwoRoundAmount(-$alloc_net), + 'quantity' => 1, + 'quantity_unit' => 'item', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'DIGITAL', + ], + ]; } /** - * Get base API host for a specific environment value (without relying on saved config) + * Remove shipping fee line items from a line-item list. + * + * @param array $items + * @return array */ - private function getTwoCheckoutHostUrlForEnvironment($environment) + private function filterTwoShippingFeeItems($items) { - return ($environment === 'production') ? 'https://api.two.inc' : 'https://api.sandbox.two.inc'; + $filtered = []; + foreach ($items as $item) { + $line_type = isset($item['type']) ? (string)$item['type'] : ''; + if ($line_type === 'SHIPPING_FEE') { + continue; + } + $filtered[] = $item; + } + + return $filtered; } /** - * Verify API key directly against selected environment using submitted API key - * Returns decoded response array on success, or false on failure + * Build discount lines using cart-rule monetary values when available. + * This preserves PrestaShop rule-level semantics better than context weighting. + * + * @param Cart $cart + * @param float $discount_net_total + * @param float $discount_gross_total + * @return array */ - private function verifyTwoApiKey($apiKey, $environment) + private function buildTwoDiscountLinesFromCartRules( + $cart, + $discount_net_total, + $discount_gross_total, + $existingItems, + &$remaining_discount_net = null, + &$remaining_discount_gross = null, + &$rule_scope_meta = null + ) { - $base = $this->getTwoCheckoutHostUrlForEnvironment($environment); - $url = $base . '/v1/merchant/verify_api_key?client=PS&client_v=' . $this->version; - $headers = [ - 'Content-Type: application/json; charset=utf-8', - 'X-API-Key:' . $apiKey, + $remaining_discount_net = round(max(0, (float)$discount_net_total), 2); + $remaining_discount_gross = round(max(0, (float)$discount_gross_total), 2); + $rule_scope_meta = [ + 'has_incomplete_rows' => false, + 'has_incomplete_free_shipping' => false, + 'incomplete_free_shipping_gross' => 0.0, ]; - PrestaShopLogger::addLog('TwoPayment: Verifying API key against ' . $base, 1); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_SHORT); - - // SSL VERIFICATION - Secure by default - $this->configureSslVerification($ch); - - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curl_error = curl_error($ch); - curl_close($ch); - - // Handle SSL/connection errors - if ($response === false || !empty($curl_error)) { - PrestaShopLogger::addLog( - 'TwoPayment: API key verification failed - cURL error: ' . $curl_error . - ' (URL: ' . $url . ')', - 3 - ); - return false; + + $cart_rules = $cart->getCartRules(); + if (empty($cart_rules)) { + return []; } - if ($httpCode !== self::HTTP_STATUS_OK || !$response) { - PrestaShopLogger::addLog('TwoPayment: API key verification failed. HTTP ' . (int)$httpCode . ' Response: ' . (is_string($response) ? $response : ''), 2); - return false; + $known_context_rates = []; + $contexts = $this->collectDiscountTaxContextsFromItems($existingItems); + foreach ($contexts as $context) { + if (isset($context['tax_rate'])) { + $known_context_rates[] = (float)$context['tax_rate']; + } } - $decoded = json_decode($response, true); - if (!is_array($decoded)) { - PrestaShopLogger::addLog('TwoPayment: API key verification returned invalid JSON', 2); - return false; + + $rule_rows = []; + $complete_rule_rows = []; + foreach ($cart_rules as $idx => $rule) { + $gross_raw = $this->extractTwoDiscountRuleGrossAmount($rule); + if ($gross_raw <= 0) { + continue; + } + + $net_raw = $this->extractTwoDiscountRuleNetAmount($rule, $gross_raw); + $gross_raw = max(0.0, (float)$gross_raw); + if ($net_raw !== null) { + $net_raw = max(0.0, (float)$net_raw); + if ($net_raw > $gross_raw) { + $net_raw = $gross_raw; + } + } else { + $rule_scope_meta['has_incomplete_rows'] = true; + if (!empty($rule['free_shipping'])) { + $rule_scope_meta['has_incomplete_free_shipping'] = true; + $rule_scope_meta['incomplete_free_shipping_gross'] = round( + $rule_scope_meta['incomplete_free_shipping_gross'] + $gross_raw, + 2 + ); + } + } + + $rule_key = (string)$idx; + $row = [ + 'rule' => $rule, + 'gross_raw' => $gross_raw, + 'net_raw' => $net_raw, + ]; + $rule_rows[$rule_key] = $row; + if ($net_raw !== null) { + $complete_rule_rows[$rule_key] = $row; + } } - PrestaShopLogger::addLog('TwoPayment: API key verified. Merchant ID: ' . (isset($decoded['id']) ? $decoded['id'] : 'N/A') . ', Short name: ' . (isset($decoded['short_name']) ? $decoded['short_name'] : 'N/A'), 1); - return $decoded; - } - /** - * Get the Two portal URL based on environment configuration - * @return string Portal URL for the current environment - */ - public function getTwoPortalUrl() - { - $environment = Configuration::get('PS_TWO_ENVIRONMENT'); - - if ($environment === 'production') { - return 'https://portal.two.inc'; - } else { - // Development environment (default) - return 'https://portal.sandbox.two.inc'; + if (empty($rule_rows)) { + return []; + } + + if (empty($complete_rule_rows)) { + return []; + } + + $gross_weights = []; + $net_weights = []; + $complete_raw_gross_total = 0.0; + $complete_raw_net_total = 0.0; + foreach ($complete_rule_rows as $rule_key => $row) { + $gross_weights[$rule_key] = (float)$row['gross_raw']; + $net_weights[$rule_key] = (float)$row['net_raw']; + $complete_raw_gross_total = round($complete_raw_gross_total + (float)$row['gross_raw'], 2); + $complete_raw_net_total = round($complete_raw_net_total + (float)$row['net_raw'], 2); + } + + $complete_gross_target = round(min((float)$discount_gross_total, $complete_raw_gross_total), 2); + $complete_net_target = round(min((float)$discount_net_total, $complete_raw_net_total), 2); + if ($complete_net_target > $complete_gross_target) { + $complete_net_target = $complete_gross_target; + } + + $allocated_gross = $this->allocateTwoAmountByWeights($complete_gross_target, $gross_weights); + $net_weight_source = array_sum($net_weights) > 0 ? $net_weights : $gross_weights; + $allocated_net = $this->allocateTwoAmountByWeights($complete_net_target, $net_weight_source); + + $lines = []; + $allocated_complete_gross_total = 0.0; + $allocated_complete_net_total = 0.0; + + foreach ($complete_rule_rows as $rule_key => $row) { + $line_gross = isset($allocated_gross[$rule_key]) ? (float)$allocated_gross[$rule_key] : 0.0; + $line_net = isset($allocated_net[$rule_key]) ? (float)$allocated_net[$rule_key] : 0.0; + + if ($line_gross <= 0) { + continue; + } + + if ($line_net < 0) { + $line_net = 0.0; + } + if ($line_net > $line_gross) { + $line_net = $line_gross; + } + + $descriptor = $this->buildTwoSingleDiscountDescriptor($row['rule']); + $line_tax = round($line_gross - $line_net, 2); + $segments = $this->buildTwoCanonicalDiscountRateSegments($line_net, $line_tax, $known_context_rates); + if (empty($segments)) { + $fallback_rate = $line_net > 0 + ? max(0, $line_tax / $line_net) + : 0.0; + $fallback_rate = $this->normalizeTwoTaxRateToPercentPrecision($fallback_rate); + $snapped_fallback_rate = $this->normalizeTwoTaxRateToPercentPrecision( + $this->snapTwoTaxRateToKnownContexts($fallback_rate, $known_context_rates) + ); + if (abs($line_tax - ($line_net * $snapped_fallback_rate)) <= self::TAX_FORMULA_TOLERANCE) { + $fallback_rate = $snapped_fallback_rate; + } + $segments = [[ + 'net' => $line_net, + 'tax' => $line_tax, + 'gross' => round($line_net + $line_tax, 2), + 'rate' => $fallback_rate, + ]]; + } + + $is_split = count($segments) > 1; + foreach ($segments as $segment) { + $segment_net = round((float)$segment['net'], 2); + $segment_tax = round((float)$segment['tax'], 2); + $segment_gross = round((float)$segment['gross'], 2); + if ($segment_gross <= 0) { + continue; + } + + $segment_rate = max(0, (float)$segment['rate']); + $segment_rate_percent = round($segment_rate * 100, 2); + $segment_name = $descriptor['name']; + if ($is_split) { + $segment_name .= ' (' . $this->l('VAT') . ' ' . $this->getTwoRoundAmount($segment_rate_percent) . '%)'; + } + + $lines[] = [ + 'name' => $segment_name, + 'description' => $descriptor['description'], + 'gross_amount' => (string)$this->getTwoRoundAmount(-$segment_gross), + 'net_amount' => (string)$this->getTwoRoundAmount(-$segment_net), + 'discount_amount' => '0.00', + 'tax_amount' => (string)$this->getTwoRoundAmount(-$segment_tax), + 'tax_class_name' => 'VAT ' . $this->getTwoRoundAmount($segment_rate_percent) . '%', + 'tax_rate' => $this->formatTwoTaxRate($segment_rate), + 'unit_price' => (string)$this->getTwoRoundAmount(-$segment_net), + 'quantity' => 1, + 'quantity_unit' => 'item', + 'image_url' => '', + 'product_page_url' => '', + 'type' => 'DIGITAL', + ]; + } + + $allocated_complete_gross_total = round($allocated_complete_gross_total + $line_gross, 2); + $allocated_complete_net_total = round($allocated_complete_net_total + $line_net, 2); + } + + if (empty($lines)) { + return []; } + + $remaining_discount_gross = round(max(0, $remaining_discount_gross - $allocated_complete_gross_total), 2); + $remaining_discount_net = round(max(0, $remaining_discount_net - $allocated_complete_net_total), 2); + if ($remaining_discount_net > $remaining_discount_gross) { + $remaining_discount_net = $remaining_discount_gross; + } + + return $lines; } /** - * Get the Two buyer portal login URL based on environment - * @return string Buyer portal login URL for the current environment + * Split a discount row into canonical tax-rate segments when possible. + * This avoids blended synthetic rates while preserving row net/tax totals. + * + * @param float $line_net Positive net amount + * @param float $line_tax Positive tax amount + * @param array $known_rates Decimal tax-rate contexts detected from positive items + * @return array */ - public function getTwoBuyerPortalUrl() + private function buildTwoCanonicalDiscountRateSegments($line_net, $line_tax, $known_rates) { - $base = $this->getTwoPortalUrl(); - return rtrim($base, '/') . '/auth/buyer/login'; + $net_cents = $this->convertAmountToCents($line_net); + $tax_cents = $this->convertAmountToCents($line_tax); + if ($net_cents <= 0 || $tax_cents < 0) { + return []; + } + + $rates = []; + foreach ((array)$known_rates as $rate) { + $normalized_rate = $this->normalizeTwoTaxRateToPercentPrecision((float)$rate); + $key = $this->formatTwoTaxRate($normalized_rate, 4); + $rates[$key] = $normalized_rate; + } + if (empty($rates)) { + return []; + } + + $rates = array_values($rates); + sort($rates, SORT_NUMERIC); + + foreach ($rates as $rate) { + if ((int)round($net_cents * $rate, 0) === $tax_cents) { + return [[ + 'net' => round($net_cents / 100, 2), + 'tax' => round($tax_cents / 100, 2), + 'gross' => round(($net_cents + $tax_cents) / 100, 2), + 'rate' => $rate, + ]]; + } + } + + if (count($rates) < 2) { + return []; + } + + $implied_rate = $net_cents > 0 ? ((float)$tax_cents / (float)$net_cents) : 0.0; + $pair_candidates = []; + for ($i = 0; $i < count($rates); $i++) { + for ($j = $i + 1; $j < count($rates); $j++) { + $low_rate = $rates[$i]; + $high_rate = $rates[$j]; + if ($high_rate <= $low_rate) { + continue; + } + + $outside_distance = 0.0; + if ($implied_rate < $low_rate) { + $outside_distance = $low_rate - $implied_rate; + } elseif ($implied_rate > $high_rate) { + $outside_distance = $implied_rate - $high_rate; + } + + $pair_candidates[] = [ + 'low' => $low_rate, + 'high' => $high_rate, + 'outside' => $outside_distance, + 'width' => $high_rate - $low_rate, + ]; + } + } + + usort($pair_candidates, function ($left, $right) { + if ($left['outside'] < $right['outside']) { + return -1; + } + if ($left['outside'] > $right['outside']) { + return 1; + } + if ($left['width'] < $right['width']) { + return -1; + } + if ($left['width'] > $right['width']) { + return 1; + } + return 0; + }); + + foreach ($pair_candidates as $pair) { + $split = $this->solveTwoRateDiscountSplitInCents( + $net_cents, + $tax_cents, + (float)$pair['low'], + (float)$pair['high'] + ); + if ($split === null) { + continue; + } + + $segments = []; + if ($split['low_net_cents'] > 0 || $split['low_tax_cents'] > 0) { + $segments[] = [ + 'net' => round($split['low_net_cents'] / 100, 2), + 'tax' => round($split['low_tax_cents'] / 100, 2), + 'gross' => round(($split['low_net_cents'] + $split['low_tax_cents']) / 100, 2), + 'rate' => (float)$pair['low'], + ]; + } + if ($split['high_net_cents'] > 0 || $split['high_tax_cents'] > 0) { + $segments[] = [ + 'net' => round($split['high_net_cents'] / 100, 2), + 'tax' => round($split['high_tax_cents'] / 100, 2), + 'gross' => round(($split['high_net_cents'] + $split['high_tax_cents']) / 100, 2), + 'rate' => (float)$pair['high'], + ]; + } + + if (!empty($segments)) { + return $segments; + } + } + + return []; } /** - * Get the PDF invoice URL for a Two order - * @param string $two_order_id The Two order ID - * @param string $lang Language code (optional, defaults to null) - * @param bool $generate Whether to generate a new PDF (optional, defaults to false) - * @param string $version Version parameter (optional, defaults to null) - * @return string PDF URL for the order + * Solve a two-rate split in cents where tax is computed as round(net * rate). + * + * @param int $net_cents + * @param int $tax_cents + * @param float $low_rate + * @param float $high_rate + * @return array|null */ - public function getTwoPdfUrl($two_order_id, $lang = null, $generate = false, $version = null) + private function solveTwoRateDiscountSplitInCents($net_cents, $tax_cents, $low_rate, $high_rate) { - $pdf_url = $this->getTwoCheckoutHostUrl() . '/v1/invoice/' . urlencode($two_order_id) . '/pdf'; - - $params = array(); - if ($generate) { - $params['generate'] = 'true'; + if ($net_cents <= 0 || $high_rate <= $low_rate) { + return null; } - if ($lang) { - $params['lang'] = $lang; + + $estimate_high_net = (int)round( + ((float)$tax_cents - ((float)$net_cents * $low_rate)) / ($high_rate - $low_rate), + 0 + ); + $estimate_high_net = max(0, min($net_cents, $estimate_high_net)); + + $max_offset = min($net_cents, 5000); + for ($offset = 0; $offset <= $max_offset; $offset++) { + $candidates = [$estimate_high_net + $offset]; + if ($offset > 0) { + $candidates[] = $estimate_high_net - $offset; + } + + foreach ($candidates as $candidate_high_net) { + if ($candidate_high_net < 0 || $candidate_high_net > $net_cents) { + continue; + } + $candidate_low_net = $net_cents - $candidate_high_net; + $candidate_low_tax = (int)round($candidate_low_net * $low_rate, 0); + $candidate_high_tax = (int)round($candidate_high_net * $high_rate, 0); + if (($candidate_low_tax + $candidate_high_tax) !== $tax_cents) { + continue; + } + + return [ + 'low_net_cents' => $candidate_low_net, + 'low_tax_cents' => $candidate_low_tax, + 'high_net_cents' => $candidate_high_net, + 'high_tax_cents' => $candidate_high_tax, + ]; + } } - if ($version) { - $params['v'] = $version; + + if ($net_cents > 50000) { + return null; } - - if (!empty($params)) { - $pdf_url .= '?' . http_build_query($params); + + for ($candidate_high_net = 0; $candidate_high_net <= $net_cents; $candidate_high_net++) { + $candidate_low_net = $net_cents - $candidate_high_net; + $candidate_low_tax = (int)round($candidate_low_net * $low_rate, 0); + $candidate_high_tax = (int)round($candidate_high_net * $high_rate, 0); + if (($candidate_low_tax + $candidate_high_tax) !== $tax_cents) { + continue; + } + + return [ + 'low_net_cents' => $candidate_low_net, + 'low_tax_cents' => $candidate_low_tax, + 'high_net_cents' => $candidate_high_net, + 'high_tax_cents' => $candidate_high_tax, + ]; } - - return $pdf_url; + + return null; } /** - * Confirm a Two order that is in VERIFIED state to move it to CONFIRMED state - * This signals that the buyer has returned to the merchant site after verification - * @param string $two_order_id The Two order ID - * @return array Result array with success status and final state + * Extract gross discount amount from a cart rule. + * + * @param array $rule + * @return float */ - public function confirmTwoOrder($two_order_id) + private function extractTwoDiscountRuleGrossAmount($rule) { - PrestaShopLogger::addLog('TwoPayment: Attempting to confirm Two order ID: ' . $two_order_id, 1); - - $confirm_response = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/confirm', [], 'POST'); - $confirm_err = $this->getTwoErrorMessage($confirm_response); - - if ($confirm_err) { - PrestaShopLogger::addLog('TwoPayment: Order confirmation failed for Two order ID: ' . $two_order_id . ', Error: ' . $confirm_err, 2); - return array( - 'success' => false, - 'error' => $confirm_err, - 'state' => null - ); - } else { - PrestaShopLogger::addLog('TwoPayment: Order successfully confirmed for Two order ID: ' . $two_order_id, 1); - return array( - 'success' => true, - 'error' => null, - 'state' => isset($confirm_response['state']) ? $confirm_response['state'] : 'CONFIRMED', - 'status' => isset($confirm_response['status']) ? $confirm_response['status'] : null, - 'response' => $confirm_response - ); + $gross_fields = ['value_real', 'value']; + foreach ($gross_fields as $field) { + if (!isset($rule[$field]) || !is_numeric($rule[$field])) { + continue; + } + + $amount = abs((float)$rule[$field]); + if ($amount > 0) { + return $amount; + } } + + // Only trust reduction_amount as gross when explicitly tax-excluded. + if ( + isset($rule['reduction_amount']) && is_numeric($rule['reduction_amount']) && + isset($rule['reduction_tax']) && (int)$rule['reduction_tax'] === 0 + ) { + $amount = abs((float)$rule['reduction_amount']); + if ($amount > 0) { + return $amount; + } + } + + return 0.0; } /** - * Get available payment terms configured by the merchant - * @return array Array of available payment terms in days + * Extract net discount amount from a cart rule. + * + * @param array $rule + * @param float $gross_amount + * @return float|null */ - public function getAvailablePaymentTerms() + private function extractTwoDiscountRuleNetAmount($rule, $gross_amount) { - $payment_terms = array_map('strval', self::PAYMENT_TERMS_OPTIONS); - $available_terms = array(); - - foreach ($payment_terms as $term) { - if (Configuration::get('PS_TWO_PAYMENT_TERMS_' . $term)) { - $available_terms[] = (int)$term; - } + if (isset($rule['value_tax_exc']) && is_numeric($rule['value_tax_exc'])) { + return abs((float)$rule['value_tax_exc']); } - - // If no terms are configured, default to DEFAULT_PAYMENT_TERM_DAYS - if (empty($available_terms)) { - $available_terms = array(self::DEFAULT_PAYMENT_TERM_DAYS); + + if (isset($rule['reduction_tax']) && (int)$rule['reduction_tax'] === 0) { + return (float)$gross_amount; } - - sort($available_terms); // Ensure they're in ascending order - return $available_terms; + + return null; } /** - * Get the default payment term (first available term or 30 days) - * @return int Default payment term in days + * Build discount line descriptor for a single cart rule. + * + * @param array $rule + * @return array{name:string,description:string} */ - public function getDefaultPaymentTerm() + private function buildTwoSingleDiscountDescriptor($rule) { - $available_terms = $this->getAvailablePaymentTerms(); - - // If only one term is available, use it as default - if (count($available_terms) === 1) { - return $available_terms[0]; - } - - // If DEFAULT_PAYMENT_TERM_DAYS is available, use it as default - if (in_array(self::DEFAULT_PAYMENT_TERM_DAYS, $available_terms)) { - return self::DEFAULT_PAYMENT_TERM_DAYS; + $name = isset($rule['name']) && !Tools::isEmpty($rule['name']) + ? trim((string)$rule['name']) + : $this->l('Discount'); + + $description = isset($rule['description']) && !Tools::isEmpty($rule['description']) + ? trim((string)$rule['description']) + : $name; + + if (!empty($rule['code'])) { + $description .= ' (' . trim((string)$rule['code']) . ')'; } - - // Otherwise, use the first available term - return !empty($available_terms) ? $available_terms[0] : self::DEFAULT_PAYMENT_TERM_DAYS; + + return [ + 'name' => $name, + 'description' => Tools::substr(strip_tags($description), 0, 255), + ]; } /** - * SHARED UTILITY: Restore duplicate cart for failed orders - * Used across multiple controllers to maintain consistency + * Build discount line descriptor from cart rules. + * + * @param Cart $cart + * @return array ['name' => string, 'description' => string] */ - public function restoreDuplicateCart($id_order, $id_customer) + private function buildTwoDiscountDescriptor($cart) { - try { - $oldCart = new Cart(Order::getCartIdStatic($id_order, $id_customer)); - if (!Validate::isLoadedObject($oldCart)) { - PrestaShopLogger::addLog('TwoPayment: Cannot restore cart - original cart not found for order ' . $id_order, 2); - return false; + $cart_rules = $cart->getCartRules(); + $discount_name = $this->l('Discount'); + $discount_description = $this->l('Order discount'); + + if (empty($cart_rules)) { + return [ + 'name' => $discount_name, + 'description' => Tools::substr(strip_tags($discount_description), 0, 255), + ]; + } + + $primary_rule = reset($cart_rules); + $discount_name = isset($primary_rule['name']) ? $primary_rule['name'] : $discount_name; + + $discount_parts = []; + foreach ($cart_rules as $rule) { + $rule_desc = isset($rule['name']) ? $rule['name'] : $this->l('Discount'); + if (!empty($rule['code'])) { + $rule_desc .= ' (' . $rule['code'] . ')'; } - - $duplication = $oldCart->duplicate(); - if (!$duplication || !isset($duplication['cart']) || !Validate::isLoadedObject($duplication['cart'])) { - PrestaShopLogger::addLog('TwoPayment: Cart duplication failed for order ' . $id_order, 2); - return false; + if (isset($rule['value']) && $rule['value']) { + if (!empty($rule['reduction_percent'])) { + $rule_desc .= ' - ' . $rule['reduction_percent'] . '%'; + } elseif (!empty($rule['reduction_amount'])) { + $rule_desc .= ' - ' . Tools::displayPrice($rule['reduction_amount']); + } } - - $this->context->cookie->id_cart = $duplication['cart']->id; - $context = $this->context; - $context->cart = $duplication['cart']; - CartRule::autoAddToCart($context); - $this->context->cookie->write(); - - PrestaShopLogger::addLog('TwoPayment: Cart restored successfully for order ' . $id_order . ', new cart ID: ' . $duplication['cart']->id, 1); - return true; - - } catch (Exception $e) { - PrestaShopLogger::addLog('TwoPayment: Exception during cart restoration for order ' . $id_order . ': ' . $e->getMessage(), 3); - return false; + $discount_parts[] = $rule_desc; + } + + $discount_description = implode(', ', $discount_parts); + if (strlen($discount_description) > 200) { + $discount_description = !empty($primary_rule['description']) + ? Tools::substr(strip_tags($primary_rule['description']), 0, 200) + : sprintf($this->l('Discount: %s'), $discount_name); + } + + if (count($cart_rules) > 1) { + $discount_name = sprintf($this->l('%s (+%d more)'), $discount_name, count($cart_rules) - 1); } + + return [ + 'name' => $discount_name, + 'description' => Tools::substr(strip_tags($discount_description), 0, 255), + ]; } /** - * SHARED UTILITY: Delete order completely from database - * Used when Two API rejects order creation (non-201 response) - * Ensures no phantom orders in PrestaShop database - * - * @param int $id_order Order ID to delete - * @return bool True on success, false on failure + * Collect tax contexts from positive existing payload lines. + * + * @param array $existingItems + * @return array */ - public function deleteOrder($id_order) + private function collectDiscountTaxContextsFromItems($existingItems) { - try { - if (!$id_order) { - PrestaShopLogger::addLog('TwoPayment: Cannot delete order - invalid ID', 3); - return false; + $contexts = []; + + foreach ($existingItems as $item) { + $line_gross = isset($item['gross_amount']) ? round((float)$item['gross_amount'], 2) : 0.0; + $line_net = isset($item['net_amount']) ? round((float)$item['net_amount'], 2) : 0.0; + if ($line_gross <= 0 || $line_net <= 0) { + continue; } - - $order = new Order((int) $id_order); - if (!Validate::isLoadedObject($order)) { - PrestaShopLogger::addLog('TwoPayment: Cannot delete order ' . $id_order . ' - not found', 2); - return false; + + $line_tax = round($line_gross - $line_net, 2); + $line_rate = isset($item['tax_rate']) ? round(max(0, (float)$item['tax_rate']), self::TAX_RATE_PRECISION) : 0.0; + $context_key = $this->formatTwoTaxRate($line_rate); + + if (!isset($contexts[$context_key])) { + $contexts[$context_key] = [ + 'tax_rate' => $line_rate, + 'net_weight' => 0.0, + 'tax_weight' => 0.0, + ]; } - - // Log order details before deletion for audit trail - PrestaShopLogger::addLog( - 'TwoPayment: Deleting order ' . $id_order . ' - ' . - 'Customer: ' . $order->id_customer . ', ' . - 'Cart: ' . $order->id_cart . ', ' . - 'Total: ' . $order->total_paid . ', ' . - 'Status: ' . $order->current_state, - 2 - ); - - // Delete Two payment data from our custom table - try { - // Use PrestaShop's delete() method for proper escaping and security - Db::getInstance()->delete('twopayment', 'id_order = ' . (int)$id_order); - PrestaShopLogger::addLog('TwoPayment: Deleted Two payment data for order ' . $id_order, 1); - } catch (Exception $e) { - PrestaShopLogger::addLog('TwoPayment: Failed to delete Two payment data for order ' . $id_order . ': ' . $e->getMessage(), 2); + + $contexts[$context_key]['net_weight'] += $line_net; + $contexts[$context_key]['tax_weight'] += max(0, $line_tax); + } + + return $contexts; + } + + /** + * Collect configured product tax rates from cart lines as decimal contexts. + * + * @param array $line_items + * @return array + */ + private function collectTwoKnownTaxRatesFromConfiguredProductRates($line_items) + { + $known_rates = []; + foreach ($line_items as $line_item) { + if (!isset($line_item['rate']) || !is_numeric($line_item['rate'])) { + continue; } - - // Use PrestaShop's native delete method (handles cascading deletes) - // This removes: order_detail, order_history, order_carrier, order_invoice, etc. - $delete_result = $order->delete(); - - if ($delete_result) { - PrestaShopLogger::addLog('TwoPayment: Successfully deleted order ' . $id_order . ' from database', 1); - return true; - } else { - PrestaShopLogger::addLog('TwoPayment: Failed to delete order ' . $id_order . ' - PrestaShop delete() returned false', 3); - return false; + + $rate_percent = max(0, (float)$line_item['rate']); + if ($rate_percent <= 0) { + continue; } - - } catch (Exception $e) { - PrestaShopLogger::addLog('TwoPayment: Exception during order deletion for order ' . $id_order . ': ' . $e->getMessage(), 3); - return false; + + $rate_decimal = $this->normalizeTwoTaxRateToPercentPrecision($rate_percent / 100); + $normalized = $this->formatTwoTaxRate($rate_decimal); + $known_rates[$normalized] = (float)$normalized; } + + return array_values($known_rates); } /** - * SHARED UTILITY: Change order status with proper validation - * Used across multiple controllers to maintain consistency + * Determine whether ES canonical tax-rate policy should be applied for this cart. + * + * @param Cart $cart + * @return bool */ - public function changeOrderStatus($id_order, $id_order_status) + private function shouldApplyTwoSpanishTaxRatePolicy($cart) { - try { - if (!$id_order || !$id_order_status) { - PrestaShopLogger::addLog('TwoPayment: Invalid parameters for order status change - Order: ' . $id_order . ', Status: ' . $id_order_status, 2); - return false; - } - - $order = new Order((int) $id_order); - if (!Validate::isLoadedObject($order)) { - PrestaShopLogger::addLog('TwoPayment: Order not found for status change: ' . $id_order, 2); - return false; + if (!Validate::isLoadedObject($cart)) { + return false; + } + + $address_ids = array_unique(array_filter([ + (int)$cart->id_address_invoice, + (int)$cart->id_address_delivery, + ])); + + foreach ($address_ids as $address_id) { + $address = new Address((int)$address_id); + if (!Validate::isLoadedObject($address)) { + continue; } - - // Only change status if it's different - if ($order->current_state == (int) $id_order_status) { - PrestaShopLogger::addLog('TwoPayment: Order ' . $id_order . ' already in target status ' . $id_order_status, 1); + + $country_iso = Country::getIsoById((int)$address->id_country); + if (is_string($country_iso) && strtoupper($country_iso) === 'ES') { return true; } - - $history = new OrderHistory(); - $history->id_order = (int) $order->id; - $history->changeIdOrderState((int) $id_order_status, $order, true); - $history->addWithemail(true); - - PrestaShopLogger::addLog('TwoPayment: Order status changed successfully for order ' . $id_order . ' to status ' . $id_order_status, 1); - return true; - - } catch (Exception $e) { - PrestaShopLogger::addLog('TwoPayment: Exception during order status change for order ' . $id_order . ': ' . $e->getMessage(), 3); - return false; } + + return false; } /** - * Get the selected payment term for the current order - * @return int Selected payment term in days + * Apply ES canonical tax-rate fallback across unresolved payload lines. + * Default fallback rate for unresolved lines is 0.21 when formula-safe. + * + * @param array $items + * @return array */ - public function getSelectedPaymentTerm() + private function applyTwoSpanishCanonicalTaxRateFallbackToItems($items) { - $available_terms = $this->getAvailablePaymentTerms(); - $default_term = $this->getDefaultPaymentTerm(); - - // Try to get from PrestaShop context cookie first - $selected_term = (int)$this->context->cookie->two_payment_term; - - // If not found, try to get from browser cookies - if (!$selected_term && isset($_COOKIE['two_payment_term'])) { - $selected_term = (int)$_COOKIE['two_payment_term']; + if (empty($items)) { + return $items; } - - PrestaShopLogger::addLog('TwoPayment: Getting payment term - Context Cookie: ' . $this->context->cookie->two_payment_term . ', Browser Cookie: ' . (isset($_COOKIE['two_payment_term']) ? $_COOKIE['two_payment_term'] : 'not set') . ', Selected: ' . $selected_term . ', Available: ' . implode(',', $available_terms) . ', Default: ' . $default_term, 1); - - if ($selected_term && in_array($selected_term, $available_terms)) { - PrestaShopLogger::addLog('TwoPayment: Using selected payment term: ' . $selected_term . ' days', 1); - return $selected_term; + + $canonical_rates = [ + 0.21, + 0.10, + 0.04, + ]; + + $known_canonical_rates = []; + foreach ($items as $item) { + if (!isset($item['tax_rate'])) { + continue; + } + + $rate = $this->normalizeTwoTaxRateToPercentPrecision((float)$item['tax_rate']); + foreach ($canonical_rates as $canonical_rate) { + if (abs($rate - $canonical_rate) <= 0.000001) { + $known_canonical_rates[(string)$canonical_rate] = $canonical_rate; + break; + } + } } - - // Fallback to default payment term - PrestaShopLogger::addLog('TwoPayment: Using default payment term: ' . $default_term . ' days', 1); - return $default_term; - } + $fallback_candidates = array_values($known_canonical_rates); + if (empty($fallback_candidates)) { + $fallback_candidates[] = self::SPANISH_FALLBACK_TAX_RATE; + } elseif (!in_array(self::SPANISH_FALLBACK_TAX_RATE, $fallback_candidates, true)) { + $fallback_candidates[] = self::SPANISH_FALLBACK_TAX_RATE; + } - public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) - { - if ($method == "POST" || $method == "PUT") { - $url = sprintf('%s%s', $this->getTwoCheckoutHostUrl(), $endpoint); - $url = $url . '?client=PS&client_v=' . $this->version; - $params = empty($payload) ? '' : json_encode($payload); - $headers = [ - 'Content-Type: application/json; charset=utf-8', - 'X-API-Key:' . $this->api_key, - ]; - - // Merge additional headers (e.g., idempotency key) - if (!empty($additional_headers) && is_array($additional_headers)) { - $headers = array_merge($headers, $additional_headers); + foreach ($items as $index => $item) { + if (!isset($item['tax_rate']) || !isset($item['net_amount']) || !isset($item['tax_amount'])) { + continue; } - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_LONG); - - // SSL VERIFICATION - Secure by default - $this->configureSslVerification($ch); - - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, $params); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - - $response_body = curl_exec($ch); - $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curl_error = curl_error($ch); - curl_close($ch); - - // Handle SSL/connection errors - if ($response_body === false || !empty($curl_error)) { - PrestaShopLogger::addLog( - 'TwoPayment: cURL error - ' . $curl_error . - ' (URL: ' . $url . ', Endpoint: ' . $endpoint . ')', - 3 - ); - - return [ - 'http_status' => 0, - 'data' => [ - 'error' => $this->l('Connection error'), - 'error_message' => 'Unable to connect to Two API. Please check your server configuration.', - 'curl_error' => $curl_error - ], - 'error' => 'Connection error', - 'error_message' => 'Unable to connect to Two API' - ]; + + $line_rate = $this->normalizeTwoTaxRateToPercentPrecision((float)$item['tax_rate']); + $line_net = round((float)$item['net_amount'], 2); + $line_tax = round((float)$item['tax_amount'], 2); + + if (abs($line_net) < 0.01) { + continue; } - - $response_data = json_decode($response_body, true); - - // Return array with HTTP status and response data for proper error handling - return [ - 'http_status' => (int)$http_status, - 'data' => $response_data, - // BACKWARD COMPATIBILITY: Merge data into root for existing code - ...(is_array($response_data) ? $response_data : []) - ]; - } else { - $url = sprintf('%s%s', $this->getTwoCheckoutHostUrl(), $endpoint); - $url = $url . '?client=PS&client_v=' . $this->version; - $headers = [ - 'Content-Type: application/json; charset=utf-8', - 'X-API-Key:' . $this->api_key, - ]; - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_LONG); - - // SSL VERIFICATION - Secure by default - $this->configureSslVerification($ch); - - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - - $response_body = curl_exec($ch); - $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curl_error = curl_error($ch); - curl_close($ch); - - // Handle SSL/connection errors - if ($response_body === false || !empty($curl_error)) { + + $is_already_canonical = false; + foreach ($canonical_rates as $canonical_rate) { + if (abs($line_rate - $canonical_rate) <= 0.000001) { + $is_already_canonical = true; + break; + } + } + if ($is_already_canonical) { + continue; + } + + $selected_rate = null; + $nearest_diff = null; + foreach ($fallback_candidates as $candidate_rate) { + $candidate_rate = $this->normalizeTwoTaxRateToPercentPrecision((float)$candidate_rate); + $diff = abs($line_rate - $candidate_rate); + if ($nearest_diff === null || $diff < $nearest_diff) { + $nearest_diff = $diff; + $selected_rate = $candidate_rate; + } + } + + if ($selected_rate === null) { + $selected_rate = self::SPANISH_FALLBACK_TAX_RATE; + } + + $is_formula_safe = abs($line_tax - ($line_net * $selected_rate)) <= self::TAX_FORMULA_TOLERANCE; + if (!$is_formula_safe && abs($line_tax - ($line_net * self::SPANISH_FALLBACK_TAX_RATE)) <= self::TAX_FORMULA_TOLERANCE) { + $selected_rate = self::SPANISH_FALLBACK_TAX_RATE; + $is_formula_safe = true; + } + + if (!$is_formula_safe) { + continue; + } + + $items[$index]['tax_rate'] = $this->formatTwoTaxRate($selected_rate); + $items[$index]['tax_class_name'] = 'VAT ' . $this->getTwoRoundAmount($selected_rate * 100) . '%'; + } + + return $items; + } + + /** + * Collect unique tax-rate contexts from positive payload lines. + * + * @param array $items + * @return array + */ + private function collectTwoKnownTaxRatesFromPositiveItems($items) + { + $known_rates = []; + foreach ($items as $item) { + $line_gross = isset($item['gross_amount']) ? round((float)$item['gross_amount'], 2) : 0.0; + $line_net = isset($item['net_amount']) ? round((float)$item['net_amount'], 2) : 0.0; + if ($line_gross <= 0 || $line_net <= 0 || !isset($item['tax_rate'])) { + continue; + } + + $normalized_rate = $this->formatTwoTaxRate((float)$item['tax_rate']); + $known_rates[$normalized_rate] = (float)$normalized_rate; + } + + return array_values($known_rates); + } + + /** + * Resolve carrier tax-rule rate as decimal (e.g. 0.21 for 21%). + * + * @param Carrier $carrier + * @param Cart $cart + * @return float + */ + private function getTwoCarrierConfiguredTaxRateDecimal($carrier, $cart) + { + if (!Validate::isLoadedObject($carrier) || !method_exists($carrier, 'getIdTaxRulesGroup')) { + return 0.0; + } + + $taxRulesGroupId = (int)$carrier->getIdTaxRulesGroup(); + if ($taxRulesGroupId <= 0) { + return 0.0; + } + + $address = new Address((int)$cart->id_address_delivery); + if (!Validate::isLoadedObject($address)) { + $address = new Address((int)$cart->id_address_invoice); + } + + try { + $taxManager = TaxManagerFactory::getManager($address, $taxRulesGroupId); + if (!is_object($taxManager) || !method_exists($taxManager, 'getTaxCalculator')) { + return 0.0; + } + + $taxCalculator = $taxManager->getTaxCalculator(); + if (!is_object($taxCalculator) || !method_exists($taxCalculator, 'getTotalRate')) { + return 0.0; + } + + $ratePercent = max(0, (float)$taxCalculator->getTotalRate()); + if ($ratePercent <= 0) { + return 0.0; + } + + return $this->normalizeTwoTaxRateToPercentPrecision($ratePercent / 100); + } catch (Exception $e) { + if (Configuration::get('PS_TWO_DEBUG_MODE')) { PrestaShopLogger::addLog( - 'TwoPayment: cURL error - ' . $curl_error . - ' (URL: ' . $url . ', Endpoint: ' . $endpoint . ')', - 3 + 'TwoPayment: Unable to resolve carrier tax rate from tax rules - ' . $e->getMessage(), + 2 ); - - return [ - 'http_status' => 0, - 'data' => [ - 'error' => 'Connection error', - 'error_message' => 'Unable to connect to Two API. Please check your server configuration.', - 'curl_error' => $curl_error - ], - 'error' => 'Connection error', - 'error_message' => 'Unable to connect to Two API' - ]; } - - $response_data = json_decode($response_body, true); - - // Return array with HTTP status and response data for proper error handling - return [ - 'http_status' => (int)$http_status, - 'data' => $response_data, - // BACKWARD COMPATIBILITY: Merge data into root for existing code - ...(is_array($response_data) ? $response_data : []) - ]; } + + return 0.0; } /** - * Configure SSL verification for cURL requests - * Secure by default, with fallback for corporate networks - * - * @param resource|CurlHandle $ch cURL handle - * @return void + * Allocate a monetary amount across weighted buckets using cent-accurate largest-remainder distribution. + * + * @param float $totalAmount Positive amount to distribute + * @param array $weights map[string]float + * @return array map[string]float (2-decimal amounts summing to totalAmount) */ - private function configureSslVerification($ch) + private function allocateTwoAmountByWeights($totalAmount, $weights) { - // Check if SSL verification is disabled via configuration (for corporate networks) - $disable_ssl_verify = (bool)Configuration::get('PS_TWO_DISABLE_SSL_VERIFY', false); - - if ($disable_ssl_verify) { - // Only if explicitly configured (corporate networks with custom certificates) - PrestaShopLogger::addLog( - 'TwoPayment: SSL verification disabled by configuration (security risk - corporate networks only)', - 2 - ); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } else { - // Enable SSL verification (secure by default) - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - - // Try to find CA certificate bundle - $ca_bundle = $this->findCaBundle(); - if ($ca_bundle) { - curl_setopt($ch, CURLOPT_CAINFO, $ca_bundle); + $total_cents = $this->convertAmountToCents($totalAmount); + if ($total_cents <= 0 || empty($weights)) { + return []; + } + + $normalized_weights = []; + foreach ($weights as $key => $weight) { + $normalized_weights[$key] = max(0.0, (float)$weight); + } + + $total_weight = array_sum($normalized_weights); + if ($total_weight <= 0) { + $normalized_weights = array_fill_keys(array_keys($weights), 1.0); + $total_weight = (float)count($normalized_weights); + } + + $allocated_cents = []; + $remainders = []; + $distributed_cents = 0; + foreach ($normalized_weights as $key => $weight) { + $raw_share = ($total_cents * $weight) / $total_weight; + $base_cents = (int)floor($raw_share); + $allocated_cents[$key] = $base_cents; + $remainders[$key] = $raw_share - $base_cents; + $distributed_cents += $base_cents; + } + + $remaining_cents = $total_cents - $distributed_cents; + if ($remaining_cents > 0) { + arsort($remainders); + $remainder_keys = array_keys($remainders); + $remainder_count = count($remainder_keys); + for ($i = 0; $i < $remaining_cents; $i++) { + $target_key = $remainder_keys[$i % $remainder_count]; + $allocated_cents[$target_key] += 1; } } + + $allocated_amounts = []; + foreach ($allocated_cents as $key => $cents) { + $allocated_amounts[$key] = round($cents / 100, 2); + } + + return $allocated_amounts; } - + /** - * Find CA certificate bundle for SSL verification - * Checks common system locations for CA certificates + * Calculate order totals from tax subtotals (Two API requirement) + * Ensures gross_amount = sum(tax_subtotals) for API validation * - * @return string|null Path to CA bundle or null if not found + * @param array $tax_subtotals Tax subtotals array from getTwoTaxSubtotals() + * @return array ['net' => float, 'tax' => float, 'gross' => float] */ - private function findCaBundle() + private function calculateOrderTotalsFromTaxSubtotals($tax_subtotals) { - $ca_locations = [ - _PS_CACHE_DIR_ . 'ca-bundle.crt', - '/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu - '/etc/pki/tls/certs/ca-bundle.crt', // CentOS/RHEL - '/usr/local/etc/openssl/cert.pem', // macOS Homebrew - '/etc/ssl/cert.pem', // Alpine Linux - '/usr/share/ssl/certs/ca-bundle.crt', // Some Linux distributions - '/opt/local/share/curl/curl-ca-bundle.crt', // macOS MacPorts + $net = 0; + $tax = 0; + foreach ($tax_subtotals as $subtotal) { + $net += (float)$subtotal['taxable_amount']; + $tax += (float)$subtotal['tax_amount']; + } + return [ + 'net' => $net, + 'tax' => $tax, + 'gross' => $net + $tax ]; + } + + /** + * Determine whether tax subtotals should be sent in outbound payloads. + * + * @return bool + */ + private function shouldIncludeTaxSubtotals() + { + return (bool)Configuration::get('PS_TWO_ENABLE_TAX_SUBTOTALS', 1); + } + + /** + * Get phone number with PrestaShop-native fallback chain + * Priority: phone → phone_mobile → empty (let Two API validate) + * + * @param Address $address PrestaShop Address object + * @return string Phone number or empty string + */ + private function getPhoneWithFallback($address) + { + // Validate address object + if (!Validate::isLoadedObject($address)) { + return ''; + } - foreach ($ca_locations as $location) { - if (file_exists($location) && is_readable($location)) { - PrestaShopLogger::addLog( - 'TwoPayment: Using CA bundle: ' . $location, - 1 - ); - return $location; - } + // Priority 1: Main phone + if (!empty($address->phone)) { + return trim($address->phone); } - // Log warning if no CA bundle found (but still try with system defaults) + // Priority 2: Mobile phone + if (!empty($address->phone_mobile)) { + return trim($address->phone_mobile); + } + + // No phone found - log warning but let Two API handle validation PrestaShopLogger::addLog( - 'TwoPayment: No CA bundle found in common locations. Using system default CA certificates.', + 'TwoPayment: No phone number found for address ID ' . $address->id . ' - Two API will validate', 2 ); - - return null; + return ''; } - public function checkTwoStartsWithString($string, $startString) - { - $len = Tools::strlen($startString); - return (Tools::substr($string, 0, $len) === $startString); + /** + * Retrieve validated company data from session cookie for a given country. + * + * @param string $country_iso + * @return array ['company_name' => string, 'organization_number' => string] + */ + public function getTwoValidatedSessionCompanyData($country_iso) + { + $country_iso = strtoupper(trim((string)$country_iso)); + $session_company = isset($this->context->cookie->two_company_name) ? trim((string)$this->context->cookie->two_company_name) : ''; + $session_company_id = isset($this->context->cookie->two_company_id) ? trim((string)$this->context->cookie->two_company_id) : ''; + $session_company_country = isset($this->context->cookie->two_company_country) ? strtoupper(trim((string)$this->context->cookie->two_company_country)) : ''; + + if (Tools::isEmpty($session_company) || Tools::isEmpty($session_company_id)) { + return array( + 'company_name' => '', + 'organization_number' => '', + ); + } + + if (Tools::isEmpty($session_company_country) && !Tools::isEmpty($country_iso)) { + // Legacy session values without country marker cannot be safely reused across countries. + unset($this->context->cookie->two_company_name); + unset($this->context->cookie->two_company_id); + unset($this->context->cookie->two_company_country); + unset($this->context->cookie->two_company_address_id); + if (method_exists($this->context->cookie, 'write')) { + $this->context->cookie->write(); + } + + PrestaShopLogger::addLog( + 'TwoPayment: Cleared legacy session company without country marker for address country=' . $country_iso, + 2 + ); + + return array( + 'company_name' => '', + 'organization_number' => '', + ); + } + + if (!Tools::isEmpty($session_company_country) && !Tools::isEmpty($country_iso) && $session_company_country !== $country_iso) { + // Prevent cross-country stale company reuse when customer changes address country. + unset($this->context->cookie->two_company_name); + unset($this->context->cookie->two_company_id); + unset($this->context->cookie->two_company_country); + unset($this->context->cookie->two_company_address_id); + if (method_exists($this->context->cookie, 'write')) { + $this->context->cookie->write(); + } + + PrestaShopLogger::addLog( + 'TwoPayment: Cleared stale session company due to country mismatch. Session country=' . + $session_company_country . ', address country=' . $country_iso, + 2 + ); + + return array( + 'company_name' => '', + 'organization_number' => '', + ); + } + + return array( + 'company_name' => $session_company, + 'organization_number' => $session_company_id, + ); + } + + /** + * Public checkout resolver for company data. + * Uses the same fallback chain as order payload building so checkout guard logic + * behaves consistently across supported countries. + * + * @param Address $address Invoice address + * @return array ['company_name' => string, 'organization_number' => string, 'country_iso' => string] + */ + public function getTwoCheckoutCompanyData($address) + { + try { + $data = $this->getCompanyDataWithFallbacks($address); + } catch (Exception $e) { + PrestaShopLogger::addLog( + 'TwoPayment: Failed resolving checkout company data - ' . $e->getMessage(), + 2 + ); + return array( + 'company_name' => '', + 'organization_number' => '', + 'country_iso' => '', + ); + } + + return array( + 'company_name' => isset($data['company_name']) ? trim((string) $data['company_name']) : '', + 'organization_number' => isset($data['organization_number']) ? trim((string) $data['organization_number']) : '', + 'country_iso' => isset($data['country_iso']) ? strtoupper(trim((string) $data['country_iso'])) : '', + ); + } + + /** + * Get company name and organization number with fallback chain + * Priority: Cookie (verified) → Address fields (dni, vat_number) → Cookie (unverified) + * + * ENHANCED: Now checks multiple address fields for org numbers across all countries, + * not just dni for Spain. This supports addresses where org numbers are stored in + * dni, vat_number, or other fields. + * + * @param Address $address Invoice or delivery address + * @return array ['company_name' => string, 'organization_number' => string, 'country_iso' => string] + */ + private function getCompanyDataWithFallbacks($address) + { + // Validate address object is loaded + if (!Validate::isLoadedObject($address)) { + PrestaShopLogger::addLog('TwoPayment: Invalid address object passed to getCompanyDataWithFallbacks', 3); + throw new Exception('Invalid address object'); + } + + // CRITICAL: Validate country ID and handle false return from Country::getIsoById() + $country_iso = Country::getIsoById($address->id_country); + if (!$country_iso || !is_string($country_iso)) { + PrestaShopLogger::addLog('TwoPayment: Invalid country ID: ' . $address->id_country . ' for address ID: ' . $address->id, 3); + throw new Exception('Invalid country in address'); + } + + $address_company = trim((string) $address->company); + $current_address_id = (int) $address->id; + $session_address_id = isset($this->context->cookie->two_company_address_id) + ? (int) $this->context->cookie->two_company_address_id + : 0; + $allow_cookie_company_fallback = true; + + // Priority 1: Session cookie (from company search - already verified and country-validated) + $validated_session_company = $this->getTwoValidatedSessionCompanyData($country_iso); + if (!empty($validated_session_company['company_name']) && !empty($validated_session_company['organization_number'])) { + $session_company_name = trim((string) $validated_session_company['company_name']); + + if ($session_address_id > 0 && $current_address_id > 0 && $session_address_id !== $current_address_id) { + PrestaShopLogger::addLog( + 'TwoPayment: Ignoring session company due to address switch. Session address=' . + $session_address_id . ', current address=' . $current_address_id, + 2 + ); + $allow_cookie_company_fallback = false; + } else { + return [ + 'company_name' => $session_company_name, + 'organization_number' => $validated_session_company['organization_number'], + 'country_iso' => $country_iso + ]; + } + } + + // Priority 2: Extract org number from address fields (dni, vat_number, companyid) + // This uses the enhanced extraction method that works across all countries + $org_number = $this->extractOrgNumberFromAddress($address, $country_iso); + + // Company name: Address → Cookie + $company_name = !Tools::isEmpty($address_company) + ? $address_company + : (($allow_cookie_company_fallback && isset($this->context->cookie->two_company_name)) + ? trim($this->context->cookie->two_company_name) + : ''); + + // If we found org number from address but no company name, we can still use it + // Two's order API will accept org number and resolve company name + + return [ + 'company_name' => $company_name, + 'organization_number' => $org_number, + 'country_iso' => $country_iso + ]; + } + + /** + * Build address array for Two API + * + * @param Address $address PrestaShop Address object + * @param string|null $organization_name Company name (may differ from address->company) + * @return array Two API address format + */ + private function buildTwoAddress($address, $organization_name = null, $country_iso = null) + { + // Validate address object is loaded + if (!Validate::isLoadedObject($address)) { + PrestaShopLogger::addLog('TwoPayment: Invalid address object passed to buildTwoAddress', 3); + throw new Exception('Invalid address object'); + } + + if ($organization_name === null) { + $organization_name = $address->company; + } + + // Use provided country_iso or fetch it (validate false return) + if ($country_iso === null) { + $country_iso = Country::getIsoById($address->id_country); + if (!$country_iso || !is_string($country_iso)) { + PrestaShopLogger::addLog('TwoPayment: Invalid country ID: ' . $address->id_country . ' for address ID: ' . $address->id, 3); + throw new Exception('Invalid country in address'); + } + } + + // Validate street_address is not empty (Two API requirement) + $street_address = trim($address->address1 . (!empty($address->address2) ? ' ' . $address->address2 : '')); + if (empty($street_address)) { + PrestaShopLogger::addLog('TwoPayment: Empty street address for address ID: ' . $address->id, 3); + // Use fallback instead of throwing (allows order to proceed) + $street_address = 'N/A'; + } + + return [ + 'city' => $address->city, + 'country' => $country_iso, + 'organization_name' => $organization_name, + 'postal_code' => $address->postcode, + 'region' => $address->id_state ? State::getNameById($address->id_state) : '', + 'street_address' => $street_address + ]; + } + + /** + * Calculate tax subtotals for Two API compliance + * Groups line items by tax rate and calculates taxable_amount and tax_amount per rate + * + * @param array $line_items Array of line items with tax_rate, net_amount, and tax_amount + * @return array Tax subtotals array for Two API + */ + public function getTwoTaxSubtotals($line_items) + { + $tax_subtotals = []; + $tax_groups = []; + + // Group line items by tax rate + foreach ($line_items as $item) { + $tax_rate = $this->formatTwoTaxRate( + isset($item['tax_rate']) ? (float)$item['tax_rate'] : 0, + self::TAX_SUBTOTAL_RATE_PRECISION + ); + // Round amounts before summing to prevent floating point precision issues + $net_amount = round((float)$item['net_amount'], 2); + $tax_amount = round((float)$item['tax_amount'], 2); + + if (!isset($tax_groups[$tax_rate])) { + $tax_groups[$tax_rate] = [ + 'taxable_amount' => 0, + 'tax_amount' => 0, + 'tax_rate' => $tax_rate + ]; + } + + // Round after each addition to prevent precision drift + $tax_groups[$tax_rate]['taxable_amount'] = round($tax_groups[$tax_rate]['taxable_amount'] + $net_amount, 2); + $tax_groups[$tax_rate]['tax_amount'] = round($tax_groups[$tax_rate]['tax_amount'] + $tax_amount, 2); + } + + // Convert to Two API format + foreach ($tax_groups as $rate => $group) { + $tax_subtotals[] = [ + 'tax_rate' => $rate, + 'taxable_amount' => (string)($this->getTwoRoundAmount($group['taxable_amount'])), + 'tax_amount' => (string)($this->getTwoRoundAmount($group['tax_amount'])) + ]; + } + + // Sort by tax rate for consistency + usort($tax_subtotals, function($a, $b) { + return (float)$a['tax_rate'] <=> (float)$b['tax_rate']; + }); + + return $tax_subtotals; + } + + /** + * Validate all line items against Two API formulas (streamlined) + * Only logs critical validation failures + * + * @param array $line_items Array of line items to validate + * @return bool True if all validations pass, false otherwise + */ + public function validateTwoLineItems($line_items) + { + $validation_issues = 0; + + foreach ($line_items as $item) { + $net_amount = (float)$item['net_amount']; + $tax_amount = (float)$item['tax_amount']; + $tax_rate = (float)$item['tax_rate']; + $unit_price = (float)$item['unit_price']; + $quantity = (int)$item['quantity']; + $discount_amount = (float)$item['discount_amount']; + + // Critical validation: tax_amount = net_amount * tax_rate (tax_rate is now decimal) + // Allow 0.01 tolerance for rounding differences (2 decimal places = ±0.005 rounding error) + $expected_tax_amount = $net_amount * $tax_rate; + if (abs($tax_amount - $expected_tax_amount) > self::TAX_FORMULA_TOLERANCE) { + PrestaShopLogger::addLog( + 'TwoPayment CRITICAL Tax Formula Error - Item: ' . $item['name'] . + ', Got: ' . $tax_amount . ', Expected: ' . $expected_tax_amount . + ' (diff: ' . abs($tax_amount - $expected_tax_amount) . ')', + 3 + ); + $validation_issues++; + } + + // Critical validation: net_amount = (quantity * unit_price) - discount_amount + // Allow 0.05 tolerance for rounding differences (accounts for multiple rounding operations) + $expected_net_amount = ($quantity * $unit_price) - $discount_amount; + if (abs($net_amount - $expected_net_amount) > self::NET_FORMULA_TOLERANCE) { + PrestaShopLogger::addLog( + 'TwoPayment CRITICAL Net Formula Error - Item: ' . $item['name'] . + ', Got: ' . $net_amount . ', Expected: ' . $expected_net_amount . + ' (diff: ' . abs($net_amount - $expected_net_amount) . ')', + 3 + ); + $validation_issues++; + } + } + + return $validation_issues === 0; + } + + /** + * Format monetary amount to 2 decimals as string (Two API requirement). + */ + public function getTwoRoundAmount($amount) + { + return number_format((float)$amount, 2, '.', ''); + } + + /** + * Format tax rate decimal to a fixed precision (2dp). + * + * @param float $tax_rate Decimal tax rate (e.g. 0.21 for 21%) + * @return string + */ + private function formatTwoTaxRate($tax_rate, $precision = null) + { + $precision = $precision === null ? self::TAX_RATE_PRECISION : (int)$precision; + $normalized = round(max(0, (float)$tax_rate), $precision); + $formatted = number_format($normalized, $precision, '.', ''); + if (strpos($formatted, '.') !== false) { + $formatted = rtrim(rtrim($formatted, '0'), '.'); + } + + return $formatted === '' ? '0' : $formatted; + } + + /** + * Normalize decimal tax rate so it carries at most 2 decimals in percent. + * + * Example: 0.210098 => 21.0098% => 21.01% => 0.2101 + * + * @param float $rate Decimal tax rate + * @return float + */ + private function normalizeTwoTaxRateToPercentPrecision($rate) + { + $percent = round(max(0, (float)$rate) * 100, self::TAX_RATE_PERCENT_PRECISION); + return $percent / 100; + } + + /** + * Snap rate to known cart contexts when only minor rounding drift exists. + * + * @param float $rate + * @param array $known_rates + * @return float + */ + private function snapTwoTaxRateToKnownContexts($rate, $known_rates) + { + $rate = max(0, (float)$rate); + if (empty($known_rates)) { + return $rate; + } + + $nearest = null; + $nearest_diff = null; + foreach ($known_rates as $candidate) { + $candidate = max(0, (float)$candidate); + $diff = abs($rate - $candidate); + if ($nearest_diff === null || $diff < $nearest_diff) { + $nearest = $candidate; + $nearest_diff = $diff; + } + } + + // Only snap when difference is tiny (pure rounding drift). + if ( + $nearest !== null && + $nearest_diff !== null && + $nearest_diff <= self::TAX_RATE_CONTEXT_SNAP_TOLERANCE + ) { + return $nearest; + } + + return $rate; + } + + /** + * Calculate effective order-level tax rate from final net and tax totals. + * + * @param float $net_amount + * @param float $tax_amount + * @return float Decimal tax rate + */ + private function calculateTwoOrderTaxRate($net_amount, $tax_amount) + { + $net_amount = (float)$net_amount; + $tax_amount = (float)$tax_amount; + + if (abs($net_amount) < 0.000001) { + return 0.0; + } + + $rate = $tax_amount / $net_amount; + if ($rate < 0) { + return 0.0; + } + + return round($rate, self::TAX_RATE_PRECISION); + } + + public function getTwoCheckoutHostUrl() + { + $environment = Configuration::get('PS_TWO_ENVIRONMENT'); + + if ($environment === 'production') { + return 'https://api.two.inc'; + } else { + // Development environment (default) + return 'https://api.sandbox.two.inc'; + } + } + + /** + * Get base API host for a specific environment value (without relying on saved config) + */ + private function getTwoCheckoutHostUrlForEnvironment($environment) + { + return ($environment === 'production') ? 'https://api.two.inc' : 'https://api.sandbox.two.inc'; + } + + /** + * Verify API key directly against selected environment using submitted API key + * Returns decoded response array on success, or false on failure + */ + private function verifyTwoApiKey($apiKey, $environment) + { + $base = $this->getTwoCheckoutHostUrlForEnvironment($environment); + $url = $base . '/v1/merchant/verify_api_key?client=PS&client_v=' . $this->version; + $headers = [ + 'Content-Type: application/json; charset=utf-8', + 'X-API-Key:' . $apiKey, + ]; + PrestaShopLogger::addLog('TwoPayment: Verifying API key against ' . $base, 1); + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_SHORT); + + // SSL VERIFICATION - Secure by default + $this->configureSslVerification($ch); + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); + + // Handle SSL/connection errors + if ($response === false || !empty($curl_error)) { + PrestaShopLogger::addLog( + 'TwoPayment: API key verification failed - cURL error: ' . $curl_error . + ' (URL: ' . $url . ')', + 3 + ); + return false; + } + + if ($httpCode !== self::HTTP_STATUS_OK || !$response) { + PrestaShopLogger::addLog('TwoPayment: API key verification failed. HTTP ' . (int)$httpCode . ' Response: ' . (is_string($response) ? $response : ''), 2); + return false; + } + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + PrestaShopLogger::addLog('TwoPayment: API key verification returned invalid JSON', 2); + return false; + } + PrestaShopLogger::addLog('TwoPayment: API key verified. Merchant ID: ' . (isset($decoded['id']) ? $decoded['id'] : 'N/A') . ', Short name: ' . (isset($decoded['short_name']) ? $decoded['short_name'] : 'N/A'), 1); + return $decoded; + } + + /** + * Get the Two portal URL based on environment configuration + * @return string Portal URL for the current environment + */ + public function getTwoPortalUrl() + { + $environment = Configuration::get('PS_TWO_ENVIRONMENT'); + + if ($environment === 'production') { + return 'https://portal.two.inc'; + } else { + // Development environment (default) + return 'https://portal.sandbox.two.inc'; + } + } + + /** + * Get the Two buyer portal login URL based on environment + * @return string Buyer portal login URL for the current environment + */ + public function getTwoBuyerPortalUrl() + { + $environment = strtolower((string) Configuration::get('PS_TWO_ENVIRONMENT')); + if ($environment === 'production') { + return 'https://buyer.two.inc/login'; + } + + // Development/non-production environments use the sandbox buyer portal. + return 'https://buyer.sandbox.two.inc/login'; + } + + /** + * Get the PDF invoice URL for a Two order + * @param string $two_order_id The Two order ID + * @param string $lang Language code (optional, defaults to null) + * @param bool $generate Whether to generate a new PDF (optional, defaults to false) + * @param string $version Version parameter (optional, defaults to null) + * @return string PDF URL for the order + */ + public function getTwoPdfUrl($two_order_id, $lang = null, $generate = false, $version = null) + { + $pdf_url = $this->getTwoCheckoutHostUrl() . '/v1/invoice/' . urlencode($two_order_id) . '/pdf'; + + $params = array(); + if ($generate) { + $params['generate'] = 'true'; + } + if ($lang) { + $params['lang'] = $lang; + } + if ($version) { + $params['v'] = $version; + } + + if (!empty($params)) { + $pdf_url .= '?' . http_build_query($params); + } + + return $pdf_url; + } + + /** + * Confirm a Two order that is in VERIFIED state to move it to CONFIRMED state + * This signals that the buyer has returned to the merchant site after verification + * @param string $two_order_id The Two order ID + * @return array Result array with success status and final state + */ + public function confirmTwoOrder($two_order_id) + { + PrestaShopLogger::addLog('TwoPayment: Attempting to confirm Two order ID: ' . $two_order_id, 1); + + $confirm_response = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id . '/confirm', [], 'POST'); + $confirm_err = $this->getTwoErrorMessage($confirm_response); + + if ($confirm_err) { + PrestaShopLogger::addLog('TwoPayment: Order confirmation failed for Two order ID: ' . $two_order_id . ', Error: ' . $confirm_err, 2); + return array( + 'success' => false, + 'error' => $confirm_err, + 'state' => null + ); + } else { + PrestaShopLogger::addLog('TwoPayment: Order successfully confirmed for Two order ID: ' . $two_order_id, 1); + return array( + 'success' => true, + 'error' => null, + 'state' => isset($confirm_response['state']) ? $confirm_response['state'] : 'CONFIRMED', + 'status' => isset($confirm_response['status']) ? $confirm_response['status'] : null, + 'response' => $confirm_response + ); + } + } + + /** + * Get available payment terms configured by the merchant + * @return array Array of available payment terms in days + */ + /** + * Get available payment terms filtered by term type (STANDARD or EOM) + * @return array Array of available payment term durations (e.g., [30, 45, 60]) + */ + public function getAvailablePaymentTerms() + { + $term_type = Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'); + + // Determine which terms to check based on type + if ($term_type === 'EOM') { + // EOM only supports 30, 45, 60 day terms + $terms_to_check = array('30', '45', '60'); + } else { + // STANDARD supports all terms + $terms_to_check = array_map('strval', self::PAYMENT_TERMS_OPTIONS); + } + + $available_terms = array(); + + foreach ($terms_to_check as $term) { + if (Configuration::get('PS_TWO_PAYMENT_TERMS_' . $term)) { + $available_terms[] = (int)$term; + } + } + + // If no terms are configured, default to DEFAULT_PAYMENT_TERM_DAYS + if (empty($available_terms)) { + $available_terms = array(self::DEFAULT_PAYMENT_TERM_DAYS); + } + + sort($available_terms); // Ensure they're in ascending order + return $available_terms; + } + + /** + * Get the default payment term (first available term or 30 days) + * @return int Default payment term in days + */ + public function getDefaultPaymentTerm() + { + $available_terms = $this->getAvailablePaymentTerms(); + + // If only one term is available, use it as default + if (count($available_terms) === 1) { + return $available_terms[0]; + } + + // If DEFAULT_PAYMENT_TERM_DAYS is available, use it as default + if (in_array(self::DEFAULT_PAYMENT_TERM_DAYS, $available_terms)) { + return self::DEFAULT_PAYMENT_TERM_DAYS; + } + + // Otherwise, use the first available term + return !empty($available_terms) ? $available_terms[0] : self::DEFAULT_PAYMENT_TERM_DAYS; + } + + /** + * SHARED UTILITY: Restore duplicate cart for failed orders + * Used across multiple controllers to maintain consistency + */ + public function restoreDuplicateCart($id_order, $id_customer) + { + try { + $oldCart = new Cart(Order::getCartIdStatic($id_order, $id_customer)); + if (!Validate::isLoadedObject($oldCart)) { + PrestaShopLogger::addLog('TwoPayment: Cannot restore cart - original cart not found for order ' . $id_order, 2); + return false; + } + + $duplication = $oldCart->duplicate(); + if (!$duplication || !isset($duplication['cart']) || !Validate::isLoadedObject($duplication['cart'])) { + PrestaShopLogger::addLog('TwoPayment: Cart duplication failed for order ' . $id_order, 2); + return false; + } + + $this->context->cookie->id_cart = $duplication['cart']->id; + $context = $this->context; + $context->cart = $duplication['cart']; + CartRule::autoAddToCart($context); + $this->context->cookie->write(); + + PrestaShopLogger::addLog('TwoPayment: Cart restored successfully for order ' . $id_order . ', new cart ID: ' . $duplication['cart']->id, 1); + return true; + + } catch (Exception $e) { + PrestaShopLogger::addLog('TwoPayment: Exception during cart restoration for order ' . $id_order . ': ' . $e->getMessage(), 3); + return false; + } + } + + /** + * SHARED UTILITY: Delete order completely from database + * Used when Two API rejects order creation (non-201 response) + * Ensures no phantom orders in PrestaShop database + * + * @param int $id_order Order ID to delete + * @return bool True on success, false on failure + */ + public function deleteOrder($id_order) + { + try { + if (!$id_order) { + PrestaShopLogger::addLog('TwoPayment: Cannot delete order - invalid ID', 3); + return false; + } + + $order = new Order((int) $id_order); + if (!Validate::isLoadedObject($order)) { + PrestaShopLogger::addLog('TwoPayment: Cannot delete order ' . $id_order . ' - not found', 2); + return false; + } + + // Log order details before deletion for audit trail + PrestaShopLogger::addLog( + 'TwoPayment: Deleting order ' . $id_order . ' - ' . + 'Customer: ' . $order->id_customer . ', ' . + 'Cart: ' . $order->id_cart . ', ' . + 'Total: ' . $order->total_paid . ', ' . + 'Status: ' . $order->current_state, + 2 + ); + + // Delete Two payment data from our custom table + try { + // Use PrestaShop's delete() method for proper escaping and security + Db::getInstance()->delete('twopayment', 'id_order = ' . (int)$id_order); + PrestaShopLogger::addLog('TwoPayment: Deleted Two payment data for order ' . $id_order, 1); + } catch (Exception $e) { + PrestaShopLogger::addLog('TwoPayment: Failed to delete Two payment data for order ' . $id_order . ': ' . $e->getMessage(), 2); + } + + // Use PrestaShop's native delete method (handles cascading deletes) + // This removes: order_detail, order_history, order_carrier, order_invoice, etc. + $delete_result = $order->delete(); + + if ($delete_result) { + PrestaShopLogger::addLog('TwoPayment: Successfully deleted order ' . $id_order . ' from database', 1); + return true; + } else { + PrestaShopLogger::addLog('TwoPayment: Failed to delete order ' . $id_order . ' - PrestaShop delete() returned false', 3); + return false; + } + + } catch (Exception $e) { + PrestaShopLogger::addLog('TwoPayment: Exception during order deletion for order ' . $id_order . ': ' . $e->getMessage(), 3); + return false; + } + } + + /** + * SHARED UTILITY: Change order status with proper validation + * Used across multiple controllers to maintain consistency + */ + public function changeOrderStatus($id_order, $id_order_status) + { + try { + if (!$id_order || !$id_order_status) { + PrestaShopLogger::addLog('TwoPayment: Invalid parameters for order status change - Order: ' . $id_order . ', Status: ' . $id_order_status, 2); + return false; + } + + $order = new Order((int) $id_order); + if (!Validate::isLoadedObject($order)) { + PrestaShopLogger::addLog('TwoPayment: Order not found for status change: ' . $id_order, 2); + return false; + } + + // Only change status if it's different + if ($order->current_state == (int) $id_order_status) { + PrestaShopLogger::addLog('TwoPayment: Order ' . $id_order . ' already in target status ' . $id_order_status, 1); + return true; + } + + $history = new OrderHistory(); + $history->id_order = (int) $order->id; + $history->changeIdOrderState((int) $id_order_status, $order, true); + $history->addWithemail(true); + + PrestaShopLogger::addLog('TwoPayment: Order status changed successfully for order ' . $id_order . ' to status ' . $id_order_status, 1); + return true; + + } catch (Exception $e) { + PrestaShopLogger::addLog('TwoPayment: Exception during order status change for order ' . $id_order . ': ' . $e->getMessage(), 3); + return false; + } + } + + /** + * Get the selected payment term for the current order + * @return int Selected payment term in days + */ + public function getSelectedPaymentTerm() + { + $available_terms = $this->getAvailablePaymentTerms(); + $default_term = $this->getDefaultPaymentTerm(); + + // Try to get from PrestaShop context cookie first + $cookie_term_raw = isset($this->context->cookie->two_payment_term) + ? (string)$this->context->cookie->two_payment_term + : ''; + $selected_term = (int)$cookie_term_raw; + + // If not found, try to get from browser cookies + if (!$selected_term && isset($_COOKIE['two_payment_term'])) { + $selected_term = (int)$_COOKIE['two_payment_term']; + } + + PrestaShopLogger::addLog('TwoPayment: Getting payment term - Context Cookie: ' . $cookie_term_raw . ', Browser Cookie: ' . (isset($_COOKIE['two_payment_term']) ? $_COOKIE['two_payment_term'] : 'not set') . ', Selected: ' . $selected_term . ', Available: ' . implode(',', $available_terms) . ', Default: ' . $default_term, 1); + + if ($selected_term && in_array($selected_term, $available_terms)) { + PrestaShopLogger::addLog('TwoPayment: Using selected payment term: ' . $selected_term . ' days', 1); + return $selected_term; + } + + // Fallback to default payment term + PrestaShopLogger::addLog('TwoPayment: Using default payment term: ' . $default_term . ' days', 1); + return $default_term; + } + + /** + * Build payment terms payload for Two API + * Adds duration_days_calculated_from for EOM terms + * + * @return array Terms payload + * + * PHP COMPATIBILITY: PHP 7.1+ compatible (no spread operators) + */ + public function buildTermsPayload() + { + $term_type = Configuration::get('PS_TWO_PAYMENT_TERM_TYPE'); + $duration_days = $this->getSelectedPaymentTerm(); + + // Base terms structure + $terms = array( + 'type' => 'NET_TERMS', + 'duration_days' => $duration_days + ); + + // Add duration_days_calculated_from for EOM terms + if ($term_type === 'EOM') { + $terms['duration_days_calculated_from'] = 'END_OF_MONTH'; + } + + return $terms; + } + + /** + * Resolve local stored payment terms from a Two order response. + * Supports STANDARD and EOM only; unsupported schemes fall back to STANDARD. + * + * @param array $order_response Two API response payload + * @param string $fallback_days Existing/fallback duration days + * @param string $fallback_type Existing/fallback term type + * @return array{two_day_on_invoice:string,two_payment_term_type:string} + */ + public function resolveTwoPaymentTermsFromOrderResponse($order_response, $fallback_days = '', $fallback_type = 'STANDARD') + { + $resolved_days = trim((string)$fallback_days); + + $resolved_type = strtoupper(trim((string)$fallback_type)); + if ($resolved_type !== 'EOM') { + $resolved_type = 'STANDARD'; + } + + if (!is_array($order_response)) { + return array( + 'two_day_on_invoice' => $resolved_days, + 'two_payment_term_type' => $resolved_type, + ); + } + + $terms_container = $order_response; + if ( + (!isset($terms_container['terms']) || !is_array($terms_container['terms'])) && + isset($order_response['data']) && + is_array($order_response['data']) + ) { + $terms_container = $order_response['data']; + } + + if (!isset($terms_container['terms']) || !is_array($terms_container['terms'])) { + return array( + 'two_day_on_invoice' => $resolved_days, + 'two_payment_term_type' => $resolved_type, + ); + } + + $terms = $terms_container['terms']; + $terms_type = isset($terms['type']) ? strtoupper(trim((string)$terms['type'])) : ''; + if (!Tools::isEmpty($terms_type) && $terms_type !== 'NET_TERMS') { + PrestaShopLogger::addLog( + 'TwoPayment: Unsupported terms.type "' . $terms_type . '" returned by Two API. Keeping fallback local term values.', + 2 + ); + return array( + 'two_day_on_invoice' => $resolved_days, + 'two_payment_term_type' => $resolved_type, + ); + } + + if (isset($terms['duration_days'])) { + $duration_days = (int)$terms['duration_days']; + if ($duration_days > 0) { + $resolved_days = (string)$duration_days; + } + } + + $calculation_scheme = isset($terms['duration_days_calculated_from']) + ? strtoupper(trim((string)$terms['duration_days_calculated_from'])) + : ''; + + if ($calculation_scheme === 'END_OF_MONTH') { + $resolved_type = 'EOM'; + } elseif (!Tools::isEmpty($calculation_scheme)) { + // Plugin intentionally supports STANDARD and EOM only. + $resolved_type = 'STANDARD'; + PrestaShopLogger::addLog( + 'TwoPayment: Unsupported duration_days_calculated_from "' . $calculation_scheme . '" returned by Two API. Storing as STANDARD.', + 2 + ); + } else { + $resolved_type = 'STANDARD'; + } + + return array( + 'two_day_on_invoice' => $resolved_days, + 'two_payment_term_type' => $resolved_type, + ); + } + + public function setTwoPaymentRequest($endpoint, $payload = [], $method = 'POST', $additional_headers = []) + { + if ($method == "POST" || $method == "PUT") { + $url = sprintf('%s%s', $this->getTwoCheckoutHostUrl(), $endpoint); + $url = $url . '?client=PS&client_v=' . $this->version; + $params = empty($payload) ? '' : json_encode($payload); + $headers = $this->getTwoRequestHeaders($endpoint, $additional_headers); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_LONG); + + // SSL VERIFICATION - Secure by default + $this->configureSslVerification($ch); + + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $params); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + + $response_body = curl_exec($ch); + $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); + + // Handle SSL/connection errors + if ($response_body === false || !empty($curl_error)) { + PrestaShopLogger::addLog( + 'TwoPayment: cURL error - ' . $curl_error . + ' (URL: ' . $url . ', Endpoint: ' . $endpoint . ')', + 3 + ); + + return [ + 'http_status' => 0, + 'data' => [ + 'error' => $this->l('Connection error'), + 'error_message' => 'Unable to connect to Two API. Please check your server configuration.', + 'curl_error' => $curl_error + ], + 'error' => 'Connection error', + 'error_message' => 'Unable to connect to Two API' + ]; + } + + $response_data = json_decode($response_body, true); + + // Return array with HTTP status and response data for proper error handling + // BACKWARD COMPATIBILITY: Merge data into root for existing code + return array_merge([ + 'http_status' => (int)$http_status, + 'data' => $response_data, + ], is_array($response_data) ? $response_data : []); + } else { + $url = sprintf('%s%s', $this->getTwoCheckoutHostUrl(), $endpoint); + $url = $url . '?client=PS&client_v=' . $this->version; + $headers = $this->getTwoRequestHeaders($endpoint, $additional_headers); + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_LONG); + + // SSL VERIFICATION - Secure by default + $this->configureSslVerification($ch); + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + + $response_body = curl_exec($ch); + $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); + + // Handle SSL/connection errors + if ($response_body === false || !empty($curl_error)) { + PrestaShopLogger::addLog( + 'TwoPayment: cURL error - ' . $curl_error . + ' (URL: ' . $url . ', Endpoint: ' . $endpoint . ')', + 3 + ); + + return [ + 'http_status' => 0, + 'data' => [ + 'error' => 'Connection error', + 'error_message' => 'Unable to connect to Two API. Please check your server configuration.', + 'curl_error' => $curl_error + ], + 'error' => 'Connection error', + 'error_message' => 'Unable to connect to Two API' + ]; + } + + $response_data = json_decode($response_body, true); + + // Return array with HTTP status and response data for proper error handling + // BACKWARD COMPATIBILITY: Merge data into root for existing code + return array_merge([ + 'http_status' => (int)$http_status, + 'data' => $response_data, + ], is_array($response_data) ? $response_data : []); + } + } + + /** + * Build outbound request headers for Two API calls. + * Security policy: never attach X-API-Key to order intent calls. + * + * @param string $endpoint + * @param array $additional_headers + * @return array + */ + public function getTwoRequestHeaders($endpoint, $additional_headers = []) + { + $headers = [ + 'Content-Type: application/json; charset=utf-8', + ]; + + $includeApiKey = $this->shouldAttachTwoApiKey($endpoint); + if ($includeApiKey && !Tools::isEmpty($this->api_key)) { + $headers[] = 'X-API-Key:' . $this->api_key; + } + + if (!empty($additional_headers) && is_array($additional_headers)) { + foreach ($additional_headers as $header) { + if (!is_string($header) || Tools::isEmpty(trim($header))) { + continue; + } + + // Hard block accidental auth header leakage on order-intent path. + if (!$includeApiKey) { + $normalizedHeader = strtolower(trim($header)); + if ( + strpos($normalizedHeader, 'x-api-key:') === 0 || + strpos($normalizedHeader, 'authorization:') === 0 || + strpos($normalizedHeader, 'proxy-authorization:') === 0 + ) { + continue; + } + } + + $headers[] = $header; + } + } + + return $headers; + } + + /** + * Determine if API key auth should be attached for a given endpoint. + * + * @param string $endpoint + * @return bool + */ + private function shouldAttachTwoApiKey($endpoint) + { + $normalized = strtolower(trim((string)$endpoint)); + if (Tools::isEmpty($normalized)) { + return true; + } + + if (strpos($normalized, 'http://') === 0 || strpos($normalized, 'https://') === 0) { + $path = parse_url($normalized, PHP_URL_PATH); + if (is_string($path)) { + $normalized = strtolower($path); + } + } else { + $query_pos = strpos($normalized, '?'); + if ($query_pos !== false) { + $normalized = substr($normalized, 0, $query_pos); + } + } + + if ($normalized === '/v1/order_intent' || strpos($normalized, '/v1/order_intent/') === 0) { + return false; + } + + return true; + } + + /** + * Configure SSL verification for cURL requests + * Secure by default, with fallback for corporate networks + * + * @param resource|CurlHandle $ch cURL handle + * @return void + */ + private function configureSslVerification($ch) + { + // Check if SSL verification is disabled via configuration (for corporate networks) + $disable_ssl_verify = (bool)Configuration::get('PS_TWO_DISABLE_SSL_VERIFY', false); + $environment = (string)Configuration::get('PS_TWO_ENVIRONMENT', 'development'); + + if ($disable_ssl_verify) { + if ($environment === 'production') { + // Production hardening: never allow insecure TLS in live traffic. + PrestaShopLogger::addLog( + 'TwoPayment: SSL verification disable flag ignored in production. Enforcing secure TLS verification.', + 3 + ); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + $ca_bundle = $this->findCaBundle(); + if ($ca_bundle) { + curl_setopt($ch, CURLOPT_CAINFO, $ca_bundle); + } + return; + } + + // Only if explicitly configured (corporate networks with custom certificates) + PrestaShopLogger::addLog( + 'TwoPayment: SSL verification disabled by configuration (security risk - corporate networks only)', + 2 + ); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + } else { + // Enable SSL verification (secure by default) + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + // Try to find CA certificate bundle + $ca_bundle = $this->findCaBundle(); + if ($ca_bundle) { + curl_setopt($ch, CURLOPT_CAINFO, $ca_bundle); + } + } + } + + /** + * Find CA certificate bundle for SSL verification + * Checks common system locations for CA certificates + * + * @return string|null Path to CA bundle or null if not found + */ + private function findCaBundle() + { + $ca_locations = [ + _PS_CACHE_DIR_ . 'ca-bundle.crt', + '/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu + '/etc/pki/tls/certs/ca-bundle.crt', // CentOS/RHEL + '/usr/local/etc/openssl/cert.pem', // macOS Homebrew + '/etc/ssl/cert.pem', // Alpine Linux + '/usr/share/ssl/certs/ca-bundle.crt', // Some Linux distributions + '/opt/local/share/curl/curl-ca-bundle.crt', // macOS MacPorts + ]; + + foreach ($ca_locations as $location) { + if (file_exists($location) && is_readable($location)) { + PrestaShopLogger::addLog( + 'TwoPayment: Using CA bundle: ' . $location, + 1 + ); + return $location; + } + } + + // Log warning if no CA bundle found (but still try with system defaults) + PrestaShopLogger::addLog( + 'TwoPayment: No CA bundle found in common locations. Using system default CA certificates.', + 2 + ); + + return null; + } + + public function checkTwoStartsWithString($string, $startString) + { + $len = Tools::strlen($startString); + return (Tools::substr($string, 0, $len) === $startString); + } + + public function getTwoErrorMessage($body) + { + if (!$body) { + return $this->l('Something went wrong please contact store owner.'); + } + + if (is_string($body)) { + // ENHANCED: Parse validation errors and return user-friendly messages + $friendly_message = $this->parseValidationErrorToFriendlyMessage($body); + if ($friendly_message) { + return $friendly_message; + } + return $body; + } + + if (!is_array($body)) { + return null; + } + + $http_status = isset($body['http_status']) ? (int)$body['http_status'] : 0; + $is_http_error = $http_status >= self::HTTP_STATUS_BAD_REQUEST; + $candidates = array($body); + if (isset($body['data']) && is_array($body['data'])) { + $candidates[] = $body['data']; + } + + foreach ($candidates as $candidate) { + $has_explicit_error_keys = isset($candidate['error']) || + isset($candidate['error_message']) || + isset($candidate['error_details']) || + isset($candidate['error_code']); + + if (isset($candidate['response']['code']) && $candidate['response'] && $candidate['response']['code'] && $candidate['response']['code'] >= self::HTTP_STATUS_BAD_REQUEST) { + return sprintf($this->l('Two response code %d'), $candidate['response']['code']); + } + + if (isset($candidate['error_details']) && $candidate['error_details']) { + $friendly_message = $this->parseValidationErrorToFriendlyMessage($candidate['error_details']); + if ($friendly_message) { + return $friendly_message; + } + return (string)$candidate['error_details']; + } + + if (isset($candidate['error_code']) && $candidate['error_code']) { + if (isset($candidate['error_message'])) { + $friendly_message = $this->parseValidationErrorToFriendlyMessage($candidate['error_message']); + if ($friendly_message) { + return $friendly_message; + } + } + if (isset($candidate['error_message']) && !Tools::isEmpty($candidate['error_message'])) { + return (string)$candidate['error_message']; + } + if (isset($candidate['message']) && !Tools::isEmpty($candidate['message'])) { + return (string)$candidate['message']; + } + } + + if (isset($candidate['error_message']) && !Tools::isEmpty($candidate['error_message'])) { + $friendly_message = $this->parseValidationErrorToFriendlyMessage($candidate['error_message']); + if ($friendly_message) { + return $friendly_message; + } + return (string)$candidate['error_message']; + } + + if (($is_http_error || $has_explicit_error_keys) && isset($candidate['message']) && !Tools::isEmpty($candidate['message'])) { + return (string)$candidate['message']; + } + + if (($is_http_error || $has_explicit_error_keys) && isset($candidate['detail']) && !Tools::isEmpty($candidate['detail'])) { + return (string)$candidate['detail']; + } + + if (isset($candidate['error']) && is_scalar($candidate['error']) && !Tools::isEmpty($candidate['error'])) { + $friendly_message = $this->parseValidationErrorToFriendlyMessage((string)$candidate['error']); + if ($friendly_message) { + return $friendly_message; + } + return (string)$candidate['error']; + } + } + + if ($http_status >= self::HTTP_STATUS_BAD_REQUEST) { + return sprintf($this->l('Two response code %d'), $http_status); + } + + return null; + } + + /** + * Build a redacted API response summary safe for production logs. + * + * @param mixed $response + * @return array + */ + public function buildTwoApiResponseLogSummary($response) + { + $summary = array( + 'http_status' => 0, + ); + + if (!is_array($response)) { + return $summary; + } + + if (isset($response['http_status'])) { + $summary['http_status'] = (int)$response['http_status']; + } + if (isset($response['id'])) { + $summary['two_order_id'] = (string)$response['id']; + } + if (isset($response['state'])) { + $summary['two_order_state'] = (string)$response['state']; + } + if (isset($response['status'])) { + $summary['two_order_status'] = (string)$response['status']; + } + if (isset($response['merchant_reference'])) { + $summary['two_order_reference'] = (string)$response['merchant_reference']; + } + + if (isset($response['error'])) { + $summary['error'] = is_scalar($response['error']) ? (string)$response['error'] : 'structured_error'; + } elseif (isset($response['error_message'])) { + $summary['error'] = (string)$response['error_message']; + } elseif (isset($response['data']) && is_array($response['data']) && isset($response['data']['error'])) { + $summary['error'] = is_scalar($response['data']['error']) ? (string)$response['data']['error'] : 'structured_error'; + } + + return $summary; + } + + /** + * Parse Two API validation errors and return user-friendly messages + * Handles common validation errors like invalid phone numbers, missing fields, etc. + * + * @param string $error_string Raw error string from Two API + * @return string|null User-friendly message or null if not a recognized pattern + */ + private function parseValidationErrorToFriendlyMessage($error_string) + { + if (!is_string($error_string)) { + return null; + } + + $error_lower = strtolower($error_string); + + // Phone number validation errors + if (strpos($error_lower, 'invalid phone number') !== false || + strpos($error_lower, 'phone_number') !== false && strpos($error_lower, 'value_error') !== false) { + return $this->l('The phone number in your billing address appears to be invalid. Please go back and ensure you have entered a valid phone number for your country.'); + } + + // Email validation errors + if (strpos($error_lower, 'invalid email') !== false || + strpos($error_lower, 'email') !== false && strpos($error_lower, 'value_error') !== false) { + return $this->l('The email address provided is invalid. Please check your email and try again.'); + } + + // Company/organization validation errors + if (strpos($error_lower, 'invalid company') !== false || + strpos($error_lower, 'organization_number') !== false && strpos($error_lower, 'value_error') !== false) { + return $this->l('The company information provided is invalid. Please go back to your billing address and search for your company name to select a valid company.'); + } + + // Address validation errors + if (strpos($error_lower, 'invalid address') !== false || + strpos($error_lower, 'address') !== false && strpos($error_lower, 'value_error') !== false) { + return $this->l('The address provided is invalid. Please go back and verify your billing address details.'); + } + + // General validation error - provide helpful generic message + if (strpos($error_lower, 'validation error') !== false || strpos($error_lower, 'value_error') !== false) { + return $this->l('Some of the information provided is invalid. Please check your billing address details and try again.'); + } + + return null; + } + + /** + * Generate a unique attempt token for the provider-first checkout flow. + * + * @param int $id_cart Cart ID + * @param int $id_customer Customer ID + * @return string + */ + public function generateTwoCheckoutAttemptToken($id_cart, $id_customer) + { + $seed = (int)$id_cart . '|' . (int)$id_customer . '|' . microtime(true) . '|' . mt_rand(); + $random = ''; + try { + $random = bin2hex(random_bytes(8)); + } catch (Exception $e) { + $random = md5($seed . '|' . uniqid('', true)); + } + + return strtolower($this->generateUuidV4FromSeed($seed . '|' . $random)); + } + + /** + * Validate whether a checkout callback is authorized for the stored attempt. + * + * @param array $attempt Attempt record from twopayment_attempt + * @param string $provided_secure_key Optional key from callback query string + * @param int $context_customer_id Current context customer ID + * @param string $context_customer_secure_key Current context customer secure key + * @return bool + */ + public function isTwoAttemptCallbackAuthorized($attempt, $provided_secure_key = '', $context_customer_id = 0, $context_customer_secure_key = '') + { + if (!is_array($attempt)) { + return false; + } + + $expected_secure_key = isset($attempt['customer_secure_key']) ? trim((string)$attempt['customer_secure_key']) : ''; + if (Tools::isEmpty($expected_secure_key)) { + return false; + } + + $provided_secure_key = trim((string)$provided_secure_key); + if (!Tools::isEmpty($provided_secure_key)) { + return hash_equals($expected_secure_key, $provided_secure_key); + } + + $attempt_customer_id = isset($attempt['id_customer']) ? (int)$attempt['id_customer'] : 0; + $context_customer_id = (int)$context_customer_id; + $context_customer_secure_key = trim((string)$context_customer_secure_key); + + if ( + $attempt_customer_id > 0 && + $context_customer_id === $attempt_customer_id && + !Tools::isEmpty($context_customer_secure_key) + ) { + return hash_equals($expected_secure_key, $context_customer_secure_key); + } + + return false; + } + + /** + * Build a compact merchant_order_id for Two order creation before local order exists. + * + * @param string $attempt_token Unique attempt token + * @param int $id_cart Cart ID + * @return string + */ + public function buildTwoMerchantOrderId($attempt_token, $id_cart) + { + $attempt_fragment = Tools::substr(str_replace('-', '', (string)$attempt_token), 0, 24); + return 'ps-cart-' . (int)$id_cart . '-att-' . $attempt_fragment; + } + + /** + * Build deterministic hash for order creation idempotency key. + * + * @param Cart $cart + * @param string $snapshot_hash + * @return string + */ + public function buildTwoOrderCreateIdempotencyKey($cart, $snapshot_hash) + { + // Keep retries idempotent for the same cart snapshot. + $seed = 'create_order|' . + (int)$cart->id . '|' . + (int)$cart->id_customer . '|' . + (string)$snapshot_hash . '|' . + (string)Configuration::get('PS_TWO_ENVIRONMENT'); + + return 'create_' . Tools::substr(hash('sha256', $seed), 0, 48); + } + + /** + * Detect whether an existing local order is already bound to a different Two order. + * + * @param array|false $existing_payment_data + * @param string $incoming_two_order_id + * @return bool + */ + public function hasTwoOrderRebindingConflict($existing_payment_data, $incoming_two_order_id) + { + if (!is_array($existing_payment_data)) { + return false; + } + + $existing_two_order_id = isset($existing_payment_data['two_order_id']) + ? trim((string)$existing_payment_data['two_order_id']) + : ''; + $incoming_two_order_id = trim((string)$incoming_two_order_id); + if (Tools::isEmpty($existing_two_order_id) || Tools::isEmpty($incoming_two_order_id)) { + return false; + } + + return !hash_equals($existing_two_order_id, $incoming_two_order_id); + } + + /** + * Calculate cart snapshot hash used to guard callback-time local order creation. + * + * @param Cart $cart + * @param array $paymentdata + * @return string + */ + public function calculateTwoCheckoutSnapshotHash($cart, $paymentdata) + { + $snapshot = $this->buildTwoCheckoutSnapshot($cart, $paymentdata); + return hash('sha256', json_encode($snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * Build normalized checkout snapshot with stable ordering. + * + * @param Cart $cart + * @param array $paymentdata + * @return array + */ + private function buildTwoCheckoutSnapshot($cart, $paymentdata) + { + $line_items = array(); + if (isset($paymentdata['line_items']) && is_array($paymentdata['line_items'])) { + foreach ($paymentdata['line_items'] as $item) { + if (!is_array($item)) { + continue; + } + $line_items[] = array( + 'type' => isset($item['type']) ? (string)$item['type'] : '', + 'quantity' => isset($item['quantity']) ? (int)$item['quantity'] : 0, + 'unit_price' => $this->normalizeSnapshotAmount(isset($item['unit_price']) ? $item['unit_price'] : 0), + 'net_amount' => $this->normalizeSnapshotAmount(isset($item['net_amount']) ? $item['net_amount'] : 0), + 'tax_amount' => $this->normalizeSnapshotAmount(isset($item['tax_amount']) ? $item['tax_amount'] : 0), + 'gross_amount' => $this->normalizeSnapshotAmount(isset($item['gross_amount']) ? $item['gross_amount'] : 0), + 'discount_amount' => $this->normalizeSnapshotAmount(isset($item['discount_amount']) ? $item['discount_amount'] : 0), + 'tax_rate' => $this->normalizeSnapshotRate(isset($item['tax_rate']) ? $item['tax_rate'] : 0), + ); + } + usort($line_items, function ($a, $b) { + return strcmp(json_encode($a), json_encode($b)); + }); + } + + $tax_subtotals = array(); + if (isset($paymentdata['tax_subtotals']) && is_array($paymentdata['tax_subtotals'])) { + foreach ($paymentdata['tax_subtotals'] as $subtotal) { + if (!is_array($subtotal)) { + continue; + } + $tax_subtotals[] = array( + 'tax_rate' => $this->normalizeSnapshotRate(isset($subtotal['tax_rate']) ? $subtotal['tax_rate'] : 0), + 'taxable_amount' => $this->normalizeSnapshotAmount(isset($subtotal['taxable_amount']) ? $subtotal['taxable_amount'] : 0), + 'tax_amount' => $this->normalizeSnapshotAmount(isset($subtotal['tax_amount']) ? $subtotal['tax_amount'] : 0), + ); + } + usort($tax_subtotals, function ($a, $b) { + return strcmp($a['tax_rate'], $b['tax_rate']); + }); + } + + return array( + 'id_cart' => (int)$cart->id, + 'id_customer' => (int)$cart->id_customer, + 'id_currency' => (int)$cart->id_currency, + 'id_address_invoice' => (int)$cart->id_address_invoice, + 'id_address_delivery' => (int)$cart->id_address_delivery, + 'id_carrier' => (int)$cart->id_carrier, + 'currency' => isset($paymentdata['currency']) ? (string)$paymentdata['currency'] : '', + 'gross_amount' => $this->normalizeSnapshotAmount(isset($paymentdata['gross_amount']) ? $paymentdata['gross_amount'] : 0), + 'net_amount' => $this->normalizeSnapshotAmount(isset($paymentdata['net_amount']) ? $paymentdata['net_amount'] : 0), + 'tax_amount' => $this->normalizeSnapshotAmount(isset($paymentdata['tax_amount']) ? $paymentdata['tax_amount'] : 0), + 'discount_amount' => $this->normalizeSnapshotAmount(isset($paymentdata['discount_amount']) ? $paymentdata['discount_amount'] : 0), + 'tax_subtotals' => $tax_subtotals, + 'line_items' => $line_items, + ); + } + + /** + * Normalize snapshot numeric fields to fixed string decimals. + * + * @param mixed $amount + * @return string + */ + private function normalizeSnapshotAmount($amount) + { + return number_format((float)$amount, 2, '.', ''); + } + + /** + * Normalize tax rate fields in checkout snapshots with fixed precision. + * + * @param mixed $rate + * @return string + */ + private function normalizeSnapshotRate($rate) + { + return number_format(max(0, (float)$rate), self::SNAPSHOT_TAX_RATE_PRECISION, '.', ''); + } + + /** + * Periodically purge stale checkout attempts to keep table size bounded. + * + * @param bool $force + * @return void + */ + public function maybeCleanupStaleTwoCheckoutAttempts($force = false) + { + $now = time(); + $last_run = (int)Configuration::get('PS_TWO_ATTEMPT_CLEANUP_LAST_RUN', 0); + if (!$force && $last_run > 0 && ($now - $last_run) < self::ATTEMPT_CLEANUP_INTERVAL_SECONDS) { + return; + } + + $cutoff = date('Y-m-d H:i:s', $now - (self::ATTEMPT_RETENTION_DAYS * 86400)); + $sql = 'DELETE FROM `' . _DB_PREFIX_ . 'twopayment_attempt` WHERE `updated_at` < "' . pSQL($cutoff) . '"'; + $ok = Db::getInstance()->execute($sql); + if (!$ok) { + PrestaShopLogger::addLog( + 'TwoPayment: Failed to purge stale checkout attempts older than ' . $cutoff, + 2 + ); + return; + } + + Configuration::updateValue('PS_TWO_ATTEMPT_CLEANUP_LAST_RUN', (string)$now); + } + + /** + * Insert or update a checkout attempt. + * + * @param string $attempt_token Unique attempt token + * @param array $attempt_data Attempt payload + * @return bool + */ + public function setTwoCheckoutAttempt($attempt_token, $attempt_data) + { + $attempt_token = trim((string)$attempt_token); + if (Tools::isEmpty($attempt_token) || !is_array($attempt_data)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + $status = isset($attempt_data['status']) ? $this->normalizeTwoAttemptStatus($attempt_data['status']) : 'CREATED'; + $secure_key = isset($attempt_data['customer_secure_key']) ? (string)$attempt_data['customer_secure_key'] : ''; + $merchant_order_id = isset($attempt_data['merchant_order_id']) ? (string)$attempt_data['merchant_order_id'] : ''; + + if (Tools::isEmpty($secure_key) || Tools::isEmpty($merchant_order_id)) { + return false; + } + + $data = array( + 'attempt_token' => pSQL($attempt_token), + 'id_cart' => isset($attempt_data['id_cart']) ? (int)$attempt_data['id_cart'] : 0, + 'id_customer' => isset($attempt_data['id_customer']) ? (int)$attempt_data['id_customer'] : 0, + 'id_order' => isset($attempt_data['id_order']) ? (int)$attempt_data['id_order'] : null, + 'customer_secure_key' => pSQL($secure_key), + 'merchant_order_id' => pSQL($merchant_order_id), + 'two_order_id' => isset($attempt_data['two_order_id']) ? pSQL($attempt_data['two_order_id']) : null, + 'two_order_reference' => isset($attempt_data['two_order_reference']) ? pSQL($attempt_data['two_order_reference']) : null, + 'two_order_state' => isset($attempt_data['two_order_state']) ? pSQL($attempt_data['two_order_state']) : null, + 'two_order_status' => isset($attempt_data['two_order_status']) ? pSQL($attempt_data['two_order_status']) : null, + 'two_day_on_invoice' => isset($attempt_data['two_day_on_invoice']) ? pSQL($attempt_data['two_day_on_invoice']) : null, + 'two_payment_term_type' => isset($attempt_data['two_payment_term_type']) ? pSQL($attempt_data['two_payment_term_type']) : 'STANDARD', + 'two_invoice_url' => isset($attempt_data['two_invoice_url']) ? pSQL($attempt_data['two_invoice_url'], true) : null, + 'two_invoice_id' => isset($attempt_data['two_invoice_id']) ? pSQL($attempt_data['two_invoice_id']) : null, + 'cart_snapshot_hash' => isset($attempt_data['cart_snapshot_hash']) ? pSQL($attempt_data['cart_snapshot_hash']) : null, + 'order_create_idempotency_key' => isset($attempt_data['order_create_idempotency_key']) ? pSQL($attempt_data['order_create_idempotency_key']) : null, + 'status' => pSQL($status), + 'updated_at' => pSQL($now), + ); + + $existing = $this->getTwoCheckoutAttempt($attempt_token); + if ($existing) { + unset($data['attempt_token']); + return Db::getInstance()->update( + 'twopayment_attempt', + $data, + 'attempt_token = "' . pSQL($attempt_token) . '"' + ); + } + + $data['created_at'] = pSQL($now); + return Db::getInstance()->insert('twopayment_attempt', $data); + } + + /** + * Retrieve a checkout attempt by token. + * + * @param string $attempt_token + * @return array|false + */ + public function getTwoCheckoutAttempt($attempt_token) + { + $attempt_token = trim((string)$attempt_token); + if (Tools::isEmpty($attempt_token)) { + return false; + } + + $sql = 'SELECT * FROM `' . _DB_PREFIX_ . 'twopayment_attempt` WHERE `attempt_token` = "' . pSQL($attempt_token) . '"'; + return Db::getInstance()->getRow($sql); + } + + /** + * Update attempt status and selected columns. + * + * @param string $attempt_token + * @param string $status + * @param array $extra_data + * @return bool + */ + public function updateTwoCheckoutAttemptStatus($attempt_token, $status, $extra_data = array()) + { + $attempt_token = trim((string)$attempt_token); + if (Tools::isEmpty($attempt_token)) { + return false; + } + + $normalized_status = $this->normalizeTwoAttemptStatus($status); + $existing_attempt = $this->getTwoCheckoutAttempt($attempt_token); + $existing_status = is_array($existing_attempt) && isset($existing_attempt['status']) ? (string)$existing_attempt['status'] : ''; + $cancelled_terminal = $this->isTwoAttemptStatusTerminal($existing_status) && !$this->isTwoAttemptStatusTerminal($normalized_status); + + if ($cancelled_terminal) { + PrestaShopLogger::addLog( + 'TwoPayment: Ignoring non-terminal attempt status transition for token ' . $attempt_token . + ' (' . strtoupper(trim((string)$existing_status)) . ' -> ' . $normalized_status . ')', + 2 + ); + $normalized_status = 'CANCELLED'; + } + + $data = array( + 'status' => pSQL($normalized_status), + 'updated_at' => pSQL(date('Y-m-d H:i:s')), + ); + + if (isset($extra_data['id_order'])) { + $data['id_order'] = (int)$extra_data['id_order']; + } + if (!$cancelled_terminal && isset($extra_data['two_order_state'])) { + $data['two_order_state'] = pSQL($extra_data['two_order_state']); + } + if (!$cancelled_terminal && isset($extra_data['two_order_status'])) { + $data['two_order_status'] = pSQL($extra_data['two_order_status']); + } + if (!$cancelled_terminal && isset($extra_data['two_day_on_invoice'])) { + $data['two_day_on_invoice'] = pSQL($extra_data['two_day_on_invoice']); + } + if (!$cancelled_terminal && isset($extra_data['two_payment_term_type'])) { + $data['two_payment_term_type'] = pSQL($extra_data['two_payment_term_type']); + } + if (!$cancelled_terminal && isset($extra_data['two_invoice_url'])) { + $data['two_invoice_url'] = pSQL($extra_data['two_invoice_url'], true); + } + if (!$cancelled_terminal && isset($extra_data['two_invoice_id'])) { + $data['two_invoice_id'] = pSQL($extra_data['two_invoice_id']); + } + if (!$cancelled_terminal && isset($extra_data['cart_snapshot_hash'])) { + $data['cart_snapshot_hash'] = pSQL($extra_data['cart_snapshot_hash']); + } + if (!$cancelled_terminal && isset($extra_data['order_create_idempotency_key'])) { + $data['order_create_idempotency_key'] = pSQL($extra_data['order_create_idempotency_key']); + } + + return Db::getInstance()->update( + 'twopayment_attempt', + $data, + 'attempt_token = "' . pSQL($attempt_token) . '"' + ); + } + + /** + * Link an attempt to the created local order. + * + * @param string $attempt_token + * @param int $id_order + * @return bool + */ + public function linkTwoCheckoutAttemptToOrder($attempt_token, $id_order) + { + return $this->updateTwoCheckoutAttemptStatus($attempt_token, 'CONFIRMED', array( + 'id_order' => (int)$id_order, + )); + } + + /** + * Update merchant_order_id for a stored checkout attempt. + * + * @param string $attempt_token + * @param string $merchant_order_id + * @return bool + */ + public function setTwoCheckoutAttemptMerchantOrderId($attempt_token, $merchant_order_id) + { + $attempt_token = trim((string)$attempt_token); + $merchant_order_id = trim((string)$merchant_order_id); + if (Tools::isEmpty($attempt_token) || Tools::isEmpty($merchant_order_id)) { + return false; + } + + return Db::getInstance()->update( + 'twopayment_attempt', + array( + 'merchant_order_id' => pSQL($merchant_order_id), + 'updated_at' => pSQL(date('Y-m-d H:i:s')), + ), + 'attempt_token = "' . pSQL($attempt_token) . '"' + ); + } + + /** + * Resolve existing order ID by cart ID with framework fallback. + * + * @param int $id_cart + * @return int + */ + public function getTwoOrderIdByCart($id_cart) + { + $id_cart = (int)$id_cart; + if ($id_cart <= 0) { + return 0; + } + + if (method_exists('Order', 'getOrderByCartId')) { + return (int)Order::getOrderByCartId($id_cart); + } + + $sql = 'SELECT `id_order` FROM `' . _DB_PREFIX_ . 'orders` WHERE `id_cart` = ' . $id_cart . ' ORDER BY `id_order` DESC'; + return (int)Db::getInstance()->getValue($sql); + } + + /** + * Resolve a local order ID from an attempt record for cancellation paths. + * Prefers direct attempt linkage and falls back to cart lookup for race windows. + * + * @param array $attempt + * @return int + */ + public function resolveTwoAttemptOrderIdForCancellation($attempt) + { + if (!is_array($attempt)) { + return 0; + } + + $attempt_order_id = isset($attempt['id_order']) ? (int)$attempt['id_order'] : 0; + if ($attempt_order_id > 0) { + return $attempt_order_id; + } + + $attempt_cart_id = isset($attempt['id_cart']) ? (int)$attempt['id_cart'] : 0; + if ($attempt_cart_id <= 0) { + return 0; + } + + return (int)$this->getTwoOrderIdByCart($attempt_cart_id); + } + + /** + * Determine whether callback confirmation must be blocked by attempt status. + * + * @param string $status + * @return bool + */ + public function shouldBlockTwoAttemptConfirmationByStatus($status) + { + $status = strtoupper(trim((string)$status)); + return $status === 'CANCELLED'; + } + + /** + * Attempt status terminality guard for race-safe state transitions. + * + * @param string $status + * @return bool + */ + public function isTwoAttemptStatusTerminal($status) + { + return $this->shouldBlockTwoAttemptConfirmationByStatus($status); + } + + /** + * Block fulfillment flows for terminal provider-cancelled orders. + * + * @param string $two_state + * @return bool + */ + public function shouldBlockTwoFulfillmentByTwoState($two_state) + { + $two_state = strtoupper(trim((string)$two_state)); + return $two_state === 'CANCELLED'; + } + + /** + * Determine whether provider order is in a fulfillable state. + * + * @param string $two_state + * @return bool + */ + public function isTwoOrderFulfillableState($two_state) + { + $two_state = strtoupper(trim((string)$two_state)); + return $two_state === 'CONFIRMED'; + } + + /** + * Resolve the local status ID used when Two order is verified and ready for fulfillment. + * + * @return int + */ + public function getTwoVerifiedPendingFulfillmentStatusId() + { + $verified_status = (int)Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT'); + if ($verified_status <= 0) { + $verified_status = (int)Configuration::get('PS_TWO_OS_VERIFIED_PENDING_FULFILLMENT_MAP'); + if ($verified_status <= 0) { + $verified_status = (int)Configuration::get('PS_OS_PREPARATION'); + } + } + + return (int)$verified_status; + } + + /** + * Determine whether a local status transition must be blocked for cancelled Two orders. + * + * @param int $status_id + * @return bool + */ + public function shouldBlockTwoStatusTransitionByCancelledState($status_id) + { + $status_id = (int)$status_id; + if ($status_id <= 0) { + return false; + } + + if ($this->isFulfillmentTriggerStatus($status_id)) { + return true; + } + + return $status_id === (int)$this->getTwoVerifiedPendingFulfillmentStatusId(); + } + + /** + * Check whether a provider order response confirms terminal cancellation. + * + * @param mixed $response + * @param int|null $http_status + * @return bool + */ + public function isTwoOrderCancelledResponse($response, $http_status = null) + { + if (!is_array($response)) { + return false; + } + + if ($http_status === null) { + $http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; + } else { + $http_status = (int)$http_status; + } + + if ($http_status <= 0 || $http_status >= self::HTTP_STATUS_BAD_REQUEST) { + return false; + } + + $state = isset($response['state']) ? strtoupper(trim((string)$response['state'])) : ''; + return $state === 'CANCELLED'; + } + + /** + * Push a warning message to the current back-office controller when available. + * + * @param string $message + * @return bool True when warning queue was updated + */ + public function addTwoBackOfficeWarning($message) + { + $message = trim((string)$message); + if (Tools::isEmpty($message)) { + return false; + } + + if (!isset($this->context) || !is_object($this->context)) { + $this->context = Context::getContext(); + } + + $controller = isset($this->context->controller) ? $this->context->controller : null; + if (!is_object($controller)) { + return false; + } + + if (!isset($controller->warnings) || !is_array($controller->warnings)) { + $controller->warnings = array(); + } + + if (!in_array($message, $controller->warnings, true)) { + $controller->warnings[] = $message; + } + + return true; + } + + /** + * Resolve configured PrestaShop cancelled status used for Two cancellations. + * + * @return int + */ + public function getTwoCancelledOrderStatusId() + { + $cancelled_status = (int)Configuration::get('PS_TWO_OS_CANCELLED'); + if ($cancelled_status <= 0) { + $cancelled_status = (int)Configuration::get('PS_TWO_OS_CANCELLED_MAP'); + if ($cancelled_status <= 0) { + $cancelled_status = (int)Configuration::get('PS_OS_CANCELED'); + } + } + + return (int)$cancelled_status; + } + + /** + * Morph a pending order-status object to the configured cancelled state profile. + * This reduces side effects (shipping stock movement, delivery toggles) when a + * cancelled Two order is forcefully moved to a fulfillment trigger status. + * + * @param object $order_status + * @param int|null $id_lang + * @return bool + */ + public function applyTwoCancelledOrderStateProfileToStatusObject($order_status, $id_lang = null) + { + if (!is_object($order_status)) { + return false; + } + + $cancelled_status = $this->getTwoCancelledOrderStatusId(); + if ($cancelled_status <= 0) { + return false; + } + + $id_lang = (int)$id_lang; + if ($id_lang <= 0) { + $id_lang = isset($this->context->language->id) ? (int)$this->context->language->id : 0; + } + + $cancelled_state = $id_lang > 0 ? new OrderState($cancelled_status, $id_lang) : new OrderState($cancelled_status); + if (!Validate::isLoadedObject($cancelled_state)) { + $cancelled_state = new OrderState($cancelled_status); + } + if (!Validate::isLoadedObject($cancelled_state)) { + return false; + } + + $order_status->id = (int)$cancelled_state->id; + $morph_fields = array('invoice', 'delivery', 'shipped', 'paid', 'logable', 'send_email', 'template', 'name', 'color', 'hidden'); + foreach ($morph_fields as $field) { + if (isset($cancelled_state->{$field})) { + $order_status->{$field} = $cancelled_state->{$field}; + } + } + + return true; + } + + /** + * Replace a pending fulfillment-trigger history row with cancelled status before insert. + * + * @param object $history + * @param object $order + * @param string $two_order_id + * @param string $source + * @param string $two_state + * @return bool + */ + public function forceTwoCancelledOrderHistoryStateBeforeInsert($history, $order, $two_order_id, $source, $two_state) + { + if (!is_object($history)) { + return false; + } + + $cancelled_status = $this->getTwoCancelledOrderStatusId(); + if ($cancelled_status <= 0) { + return false; + } + + $history->id_order_state = $cancelled_status; + + if (is_object($order) && Validate::isLoadedObject($order)) { + $order->current_state = $cancelled_status; + + $cancelled_state = isset($order->id_lang) ? new OrderState($cancelled_status, (int)$order->id_lang) : new OrderState($cancelled_status); + if (!Validate::isLoadedObject($cancelled_state)) { + $cancelled_state = new OrderState($cancelled_status); + } + $order->valid = (Validate::isLoadedObject($cancelled_state) && isset($cancelled_state->logable)) ? (bool)$cancelled_state->logable : false; + $order->update(); + + if (method_exists('Order', 'cleanHistoryCache')) { + Order::cleanHistoryCache(); + } + } + + $this->addTwoBackOfficeWarning($this->l('Fulfillment blocked: this Two order is cancelled at provider. The order status has been reverted to cancelled.')); + PrestaShopLogger::addLog( + 'TwoPayment: Blocked fulfillment status insert for cancelled Two order ' . $two_order_id . + ' (state=' . strtoupper(trim((string)$two_state)) . ', source=' . trim((string)$source) . '). ' . + 'History row was rewritten to cancelled.', + 2 + ); + + return true; } - public function getTwoErrorMessage($body) + /** + * Keep local order state aligned when provider reports terminal cancellation. + * + * @param int $id_order + * @param string $two_state + * @return bool + */ + public function syncLocalOrderStatusFromTwoState($id_order, $two_state) { - if (!$body) { - return $this->l('Something went wrong please contact store owner.'); + $id_order = (int)$id_order; + if ($id_order <= 0) { + return false; } - if (isset($body['response']['code']) && $body['response'] && $body['response']['code'] && $body['response']['code'] >= self::HTTP_STATUS_BAD_REQUEST) { - return sprintf($this->l('Two response code %d'), $body['response']['code']); + $two_state = strtoupper(trim((string)$two_state)); + if ($two_state !== 'CANCELLED') { + return false; } - if (is_string($body)) { - return $body; + $cancelled_status = $this->getTwoCancelledOrderStatusId(); + if ($cancelled_status <= 0) { + return false; } - if (isset($body['error_details']) && $body['error_details']) { - return $body['error_details']; - } + return (bool)$this->changeOrderStatus($id_order, $cancelled_status); + } - if (isset($body['error_code']) && $body['error_code']) { - return $body['error_message']; + /** + * Ensure attempt status values are consistent. + * + * @param string $status + * @return string + */ + private function normalizeTwoAttemptStatus($status) + { + $status = strtoupper((string)$status); + $allowed = array('CREATED', 'REDIRECTED', 'CONFIRMED', 'CANCELLED', 'FAILED'); + if (!in_array($status, $allowed, true)) { + return 'FAILED'; } + return $status; } public function setTwoOrderPaymentData($id_order, $payment_data) { // PrestaShop standard: (int) casting prevents SQL injection for integer IDs $id_order = (int)$id_order; + $result = $this->getTwoOrderPaymentData($id_order); + $data = array( + 'id_order' => pSQL($id_order), + 'two_order_id' => pSQL($payment_data['two_order_id']), + 'two_order_reference' => pSQL($payment_data['two_order_reference']), + 'two_order_state' => pSQL($payment_data['two_order_state']), + 'two_order_status' => pSQL($payment_data['two_order_status']), + 'two_day_on_invoice' => pSQL($payment_data['two_day_on_invoice']), + 'two_invoice_url' => pSQL($payment_data['two_invoice_url']), + 'two_invoice_id' => isset($payment_data['two_invoice_id']) ? pSQL($payment_data['two_invoice_id']) : null, + 'two_payment_term_type' => isset($payment_data['two_payment_term_type']) ? pSQL($payment_data['two_payment_term_type']) : 'STANDARD', + ); + // Note: invoice_details (payment info) is NOT stored in DB - fetched from Two API when needed + if ($result) { - $data = array( - 'id_order' => pSQL($id_order), - 'two_order_id' => pSQL($payment_data['two_order_id']), - 'two_order_reference' => pSQL($payment_data['two_order_reference']), - 'two_order_state' => pSQL($payment_data['two_order_state']), - 'two_order_status' => pSQL($payment_data['two_order_status']), - 'two_day_on_invoice' => pSQL($payment_data['two_day_on_invoice']), - 'two_invoice_url' => pSQL($payment_data['two_invoice_url']), - 'two_invoice_id' => isset($payment_data['two_invoice_id']) ? pSQL($payment_data['two_invoice_id']) : null, - ); Db::getInstance()->update('twopayment', $data, 'id_order = ' . (int) $id_order); } else { - $data = array( - 'id_order' => pSQL($id_order), - 'two_order_id' => pSQL($payment_data['two_order_id']), - 'two_order_reference' => pSQL($payment_data['two_order_reference']), - 'two_order_state' => pSQL($payment_data['two_order_state']), - 'two_order_status' => pSQL($payment_data['two_order_status']), - 'two_day_on_invoice' => pSQL($payment_data['two_day_on_invoice']), - 'two_invoice_url' => pSQL($payment_data['two_invoice_url']), - 'two_invoice_id' => isset($payment_data['two_invoice_id']) ? pSQL($payment_data['two_invoice_id']) : null, - ); Db::getInstance()->insert('twopayment', $data); } } @@ -3121,6 +6917,286 @@ public function getTwoOrderPaymentData($id_order) return $result; } + /** + * Merge fallback payment-term data into order payment data without overriding + * already persisted values. + * + * @param array $twopaymentdata Primary order payment row + * @param array|false $fallback_data Fallback row (attempt/API) + * @return array + */ + public function mergeTwoPaymentTermFallback($twopaymentdata, $fallback_data) + { + if (!is_array($twopaymentdata)) { + return array(); + } + + if (!is_array($fallback_data)) { + return $twopaymentdata; + } + + $merged = $twopaymentdata; + $current_days = isset($merged['two_day_on_invoice']) ? trim((string)$merged['two_day_on_invoice']) : ''; + $current_type = isset($merged['two_payment_term_type']) ? trim((string)$merged['two_payment_term_type']) : ''; + $fallback_days = isset($fallback_data['two_day_on_invoice']) ? trim((string)$fallback_data['two_day_on_invoice']) : ''; + $fallback_type = isset($fallback_data['two_payment_term_type']) ? trim((string)$fallback_data['two_payment_term_type']) : ''; + + if (Tools::isEmpty($current_days) && !Tools::isEmpty($fallback_days)) { + $merged['two_day_on_invoice'] = $fallback_days; + } + + if (Tools::isEmpty($current_type) && !Tools::isEmpty($fallback_type)) { + $merged['two_payment_term_type'] = $fallback_type; + } + + return $merged; + } + + /** + * Invoice links should only be exposed once Two marks the order as fulfilled. + * + * @param array $twopaymentdata + * @return bool + */ + public function shouldExposeTwoInvoiceActions($twopaymentdata) + { + if (!is_array($twopaymentdata)) { + return false; + } + + $state = isset($twopaymentdata['two_order_state']) ? strtoupper(trim((string)$twopaymentdata['two_order_state'])) : ''; + return $state === 'FULFILLED'; + } + + /** + * Refresh admin order payment data from Two API GET /v1/order/{id}. + * Falls back to stored snapshot if provider call fails. + * + * @param int $id_order + * @param array $twopaymentdata + * @return array + */ + protected function syncTwoAdminOrderPaymentDataFromProvider($id_order, $twopaymentdata) + { + if (!is_array($twopaymentdata)) { + return array(); + } + + $id_order = (int)$id_order; + $two_order_id = $this->resolveTwoOrderIdForAdmin($id_order, $twopaymentdata); + if ($id_order <= 0 || Tools::isEmpty($two_order_id)) { + return $twopaymentdata; + } + + if (!isset($twopaymentdata['two_order_id']) || Tools::isEmpty($twopaymentdata['two_order_id'])) { + $twopaymentdata['two_order_id'] = $two_order_id; + } + + // Avoid duplicate provider calls when both admin hooks render on the same request. + static $request_cache = array(); + $cache_key = $id_order . ':' . $two_order_id; + if (isset($request_cache[$cache_key])) { + return $request_cache[$cache_key]; + } + + $response = $this->setTwoPaymentRequest('/v1/order/' . $two_order_id, array(), 'GET'); + $http_status = isset($response['http_status']) ? (int)$response['http_status'] : 0; + $order_payload = $this->extractTwoOrderPayloadFromApiResponse($response); + if ( + $http_status !== self::HTTP_STATUS_OK || + !is_array($order_payload) || + !isset($order_payload['id']) || + Tools::isEmpty($order_payload['id']) + ) { + if ($http_status > 0) { + PrestaShopLogger::addLog( + 'TwoPayment: Admin order sync failed for id_order ' . $id_order . ', Two order ' . $two_order_id . ', HTTP ' . $http_status, + 2 + ); + } + $request_cache[$cache_key] = $twopaymentdata; + return $twopaymentdata; + } + + $existing_days = isset($twopaymentdata['two_day_on_invoice']) ? (string)$twopaymentdata['two_day_on_invoice'] : ''; + $existing_type = isset($twopaymentdata['two_payment_term_type']) ? $twopaymentdata['two_payment_term_type'] : 'STANDARD'; + $resolved_terms = $this->resolveTwoPaymentTermsFromOrderResponse($order_payload, $existing_days, $existing_type); + + $updated = array( + 'two_order_id' => (string)$order_payload['id'], + 'two_order_reference' => isset($order_payload['merchant_reference']) ? (string)$order_payload['merchant_reference'] : (isset($twopaymentdata['two_order_reference']) ? (string)$twopaymentdata['two_order_reference'] : ''), + 'two_order_state' => isset($order_payload['state']) ? (string)$order_payload['state'] : (isset($twopaymentdata['two_order_state']) ? (string)$twopaymentdata['two_order_state'] : ''), + 'two_order_status' => isset($order_payload['status']) ? (string)$order_payload['status'] : (isset($twopaymentdata['two_order_status']) ? (string)$twopaymentdata['two_order_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($order_payload['invoice_url']) ? (string)$order_payload['invoice_url'] : (isset($twopaymentdata['two_invoice_url']) ? (string)$twopaymentdata['two_invoice_url'] : ''), + 'two_invoice_id' => isset($order_payload['invoice_details']['id']) ? (string)$order_payload['invoice_details']['id'] : (isset($twopaymentdata['two_invoice_id']) ? (string)$twopaymentdata['two_invoice_id'] : ''), + ); + + $compare_fields = array( + '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', + ); + + $changed = false; + foreach ($compare_fields as $field) { + $old_value = isset($twopaymentdata[$field]) ? trim((string)$twopaymentdata[$field]) : ''; + $new_value = isset($updated[$field]) ? trim((string)$updated[$field]) : ''; + if ($old_value !== $new_value) { + $changed = true; + break; + } + } + + if ($changed) { + $this->setTwoOrderPaymentData($id_order, $updated); + } + + $synced = $twopaymentdata; + foreach ($updated as $field => $value) { + $synced[$field] = $value; + } + + $this->syncLocalOrderStatusFromTwoState( + $id_order, + isset($synced['two_order_state']) ? (string)$synced['two_order_state'] : '' + ); + + $request_cache[$cache_key] = $synced; + return $synced; + } + + /** + * Resolve Two order ID for admin sync from persisted order row or attempt fallback. + * + * @param int $id_order + * @param array $twopaymentdata + * @return string + */ + protected function resolveTwoOrderIdForAdmin($id_order, $twopaymentdata) + { + $id_order = (int)$id_order; + if ($id_order <= 0 || !is_array($twopaymentdata)) { + return ''; + } + + $two_order_id = isset($twopaymentdata['two_order_id']) ? trim((string)$twopaymentdata['two_order_id']) : ''; + if (!Tools::isEmpty($two_order_id)) { + return $two_order_id; + } + + $attempt = $this->getLatestTwoCheckoutAttemptByOrder($id_order); + if (is_array($attempt)) { + $attempt_two_order_id = isset($attempt['two_order_id']) ? trim((string)$attempt['two_order_id']) : ''; + if (!Tools::isEmpty($attempt_two_order_id)) { + return $attempt_two_order_id; + } + } + + return ''; + } + + /** + * Normalize Two API order payload shape for handlers that may return wrapped data. + * + * @param mixed $response + * @return array + */ + protected function extractTwoOrderPayloadFromApiResponse($response) + { + if (!is_array($response)) { + return array(); + } + + if (isset($response['id']) && !Tools::isEmpty($response['id'])) { + return $response; + } + + if (isset($response['data']) && is_array($response['data'])) { + if (isset($response['data']['id']) && !Tools::isEmpty($response['data']['id'])) { + return $response['data']; + } + + if ( + isset($response['data']['order']) && + is_array($response['data']['order']) && + isset($response['data']['order']['id']) && + !Tools::isEmpty($response['data']['order']['id']) + ) { + return $response['data']['order']; + } + } + + return array(); + } + + /** + * Get latest persisted attempt data for a local order (if available). + * + * @param int $id_order + * @return array|false + */ + protected function getLatestTwoCheckoutAttemptByOrder($id_order) + { + $id_order = (int)$id_order; + if ($id_order <= 0) { + return false; + } + + $sql = 'SELECT `two_order_id`, `status`, `two_day_on_invoice`, `two_payment_term_type`, `two_order_state`, `two_order_status`, `two_invoice_url`, `two_invoice_id` ' . + 'FROM `' . _DB_PREFIX_ . 'twopayment_attempt` ' . + 'WHERE `id_order` = ' . (int)$id_order . ' ' . + 'ORDER BY `updated_at` DESC, `id_attempt` DESC'; + $rows = Db::getInstance()->executeS($sql); + + if (!is_array($rows) || empty($rows)) { + return false; + } + + return $rows[0]; + } + + /** + * Enrich admin order data with fallback values and persist repaired terms. + * + * @param int $id_order + * @param array $twopaymentdata + * @return array + */ + protected function enrichTwoAdminOrderPaymentData($id_order, $twopaymentdata) + { + if (!is_array($twopaymentdata)) { + return array(); + } + + $fallback_data = $this->getLatestTwoCheckoutAttemptByOrder((int)$id_order); + $merged = $this->mergeTwoPaymentTermFallback($twopaymentdata, $fallback_data); + + $updated_days = isset($merged['two_day_on_invoice']) ? trim((string)$merged['two_day_on_invoice']) : ''; + $updated_type = isset($merged['two_payment_term_type']) ? trim((string)$merged['two_payment_term_type']) : ''; + $original_days = isset($twopaymentdata['two_day_on_invoice']) ? trim((string)$twopaymentdata['two_day_on_invoice']) : ''; + $original_type = isset($twopaymentdata['two_payment_term_type']) ? trim((string)$twopaymentdata['two_payment_term_type']) : ''; + + if ($updated_days !== $original_days || $updated_type !== $original_type) { + Db::getInstance()->update( + 'twopayment', + array( + 'two_day_on_invoice' => $updated_days, + 'two_payment_term_type' => $updated_type, + ), + 'id_order = ' . (int)$id_order + ); + } + + return $merged; + } + public function hookDisplayPaymentReturn($params) { $id_order = $params['order']->id; @@ -3142,6 +7218,7 @@ public function hookDisplayPaymentReturn($params) 'two_day_on_invoice' => $twopaymentdata['two_day_on_invoice'], 'two_invoice_url' => $twopaymentdata['two_invoice_url'], 'two_invoice_id' => isset($confirm_result['invoice_details']['id']) ? $confirm_result['invoice_details']['id'] : $twopaymentdata['two_invoice_id'], + 'two_payment_term_type' => isset($twopaymentdata['two_payment_term_type']) ? $twopaymentdata['two_payment_term_type'] : 'STANDARD', ); $this->setTwoOrderPaymentData($id_order, $payment_data); @@ -3192,9 +7269,13 @@ public function hookDisplayAdminOrderLeft($params) $id_order = $params['id_order']; $twopaymentdata = $this->getTwoOrderPaymentData($id_order); if ($twopaymentdata) { + $twopaymentdata = $this->syncTwoAdminOrderPaymentDataFromProvider((int)$id_order, $twopaymentdata); + $twopaymentdata = $this->enrichTwoAdminOrderPaymentData((int)$id_order, $twopaymentdata); + $invoice_actions_available = $this->shouldExposeTwoInvoiceActions($twopaymentdata); + // Generate PDF URL if Two order ID is available $pdf_url = null; - if (!empty($twopaymentdata['two_order_id'])) { + if ($invoice_actions_available && !empty($twopaymentdata['two_order_id'])) { $pdf_url = $this->getTwoPdfUrl($twopaymentdata['two_order_id']); } @@ -3202,6 +7283,7 @@ public function hookDisplayAdminOrderLeft($params) 'twopaymentdata' => $twopaymentdata, 'two_portal_url' => $this->getTwoPortalUrl(), // Dynamic portal URL based on environment 'two_pdf_url' => $pdf_url, // PDF invoice URL if available + 'two_invoice_actions_available' => $invoice_actions_available, )); return $this->context->smarty->fetch('module:twopayment/views/templates/hook/displayAdminOrderLeft.tpl'); } @@ -3222,9 +7304,13 @@ public function hookDisplayAdminOrderTabContent($params) $twopaymentdata = $this->getTwoOrderPaymentData($id_order); if ($twopaymentdata) { + $twopaymentdata = $this->syncTwoAdminOrderPaymentDataFromProvider((int)$id_order, $twopaymentdata); + $twopaymentdata = $this->enrichTwoAdminOrderPaymentData((int)$id_order, $twopaymentdata); + $invoice_actions_available = $this->shouldExposeTwoInvoiceActions($twopaymentdata); + // Generate PDF URL if Two order ID is available $pdf_url = null; - if (!empty($twopaymentdata['two_order_id'])) { + if ($invoice_actions_available && !empty($twopaymentdata['two_order_id'])) { $pdf_url = $this->getTwoPdfUrl($twopaymentdata['two_order_id']); } @@ -3232,7 +7318,8 @@ public function hookDisplayAdminOrderTabContent($params) 'twopaymentdata' => $twopaymentdata, 'two_portal_url' => $this->getTwoPortalUrl(), // Dynamic portal URL based on environment 'two_pdf_url' => $pdf_url, // PDF invoice URL if available - 'use_own_invoices' => (bool)Configuration::get('PS_TWO_USE_OWN_INVOICES'), // Show invoice upload section if enabled + 'use_own_invoices' => (bool)Configuration::get('PS_TWO_USE_OWN_INVOICES'), + 'two_invoice_actions_available' => $invoice_actions_available, )); return $this->context->smarty->fetch('module:twopayment/views/templates/hook/displayAdminOrderTabContent.tpl'); } @@ -3258,6 +7345,9 @@ public function hookActionCustomerAddressSave($params) // Store company data in session for persistence across checkout steps if (isset($this->context->cookie)) { $this->context->cookie->two_company_name = $address->company; + if (!empty($address->id)) { + $this->context->cookie->two_company_address_id = (string) (int) $address->id; + } // Try to get organization number from form data if available $companyId = Tools::getValue('companyid', ''); @@ -3405,5 +7495,220 @@ private function uploadInvoiceAfterFulfillment($id_order, $orderpaymentdata) ); } } + + /** + * Verify and resolve company data using organization number via Two's company search API + * + * CRITICAL: This enables smart UX for logged-in users with existing addresses. + * Instead of searching by company name, we search by organization + * number which gives an EXACT match. + * + * Two's API supports searching by org number: /companies/v2/company?q={org_number}&country={iso} + * Example: https://api.two.inc/companies/v2/company?q=A81304487&country=ES + * + * @param string $orgNumber The organization number to search for (CIF, NIF, company number, etc.) + * @param string $countryIso Two-letter country ISO code (e.g., 'GB', 'NO', 'SE', 'ES') + * @return array|null Returns ['name' => string, 'organization_number' => string] on success, null on failure + */ + public function verifyCompanyByOrgNumber($orgNumber, $countryIso) + { + if (empty($orgNumber) || empty($countryIso)) { + return null; + } + + // Normalize inputs + $orgNumber = trim($orgNumber); + $countryIso = strtoupper(trim($countryIso)); + + // Build the search URL - search by organization number for exact match + // This is the key insight: Two's API accepts org numbers in the 'q' parameter + $searchUrl = $this->getTwoCheckoutHostUrl() . '/companies/v2/company'; + $searchUrl .= '?' . http_build_query([ + 'q' => $orgNumber, + 'country' => $countryIso + ]); + + PrestaShopLogger::addLog( + 'TwoPayment: Verifying company by org number: ' . $orgNumber . ' in ' . $countryIso, + 1 + ); + + // Make the request (no API key required for company search) + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $searchUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, self::API_TIMEOUT_SHORT); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Accept: application/json' + ]); + + // Configure SSL verification + $this->configureSslVerification($ch); + + $response_body = curl_exec($ch); + $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); + + // Handle errors + if ($response_body === false || !empty($curl_error) || $http_status !== 200) { + PrestaShopLogger::addLog( + 'TwoPayment: Company verification failed - HTTP ' . $http_status . + ', Error: ' . ($curl_error ?: 'Unknown') . + ', OrgNumber: ' . $orgNumber . ', Country: ' . $countryIso, + 2 + ); + return null; + } + + $response = json_decode($response_body, true); + + if (!isset($response['items']) || !is_array($response['items']) || empty($response['items'])) { + PrestaShopLogger::addLog( + 'TwoPayment: Company verification - no results for org number: ' . $orgNumber . ' in ' . $countryIso, + 1 + ); + return null; + } + + $companies = $response['items']; + + // When searching by org number, we expect an exact match + // The API should return the company with matching organization number + foreach ($companies as $company) { + $foundOrgNumber = $this->extractOrganizationNumber($company); + + // Normalize both for comparison (remove spaces, dashes, make uppercase) + $normalizedSearch = strtoupper(preg_replace('/[\s\-]/', '', $orgNumber)); + $normalizedFound = strtoupper(preg_replace('/[\s\-]/', '', $foundOrgNumber)); + + if ($normalizedSearch === $normalizedFound) { + PrestaShopLogger::addLog( + 'TwoPayment: ✓ Company verified by org number - ' . $orgNumber . + ' => ' . $company['name'] . ' in ' . $countryIso, + 1 + ); + return [ + 'name' => $company['name'], + 'organization_number' => $foundOrgNumber + ]; + } + } + + // If no exact org number match, but we got a single result, it might be valid + // (API might have found it via partial match) + if (count($companies) === 1) { + $company = $companies[0]; + $foundOrgNumber = $this->extractOrganizationNumber($company); + if (!empty($foundOrgNumber)) { + PrestaShopLogger::addLog( + 'TwoPayment: ✓ Company resolved (single result) - searched: ' . $orgNumber . + ' => ' . $company['name'] . ' (' . $foundOrgNumber . ') in ' . $countryIso, + 1 + ); + return [ + 'name' => $company['name'], + 'organization_number' => $foundOrgNumber + ]; + } + } + + PrestaShopLogger::addLog( + 'TwoPayment: Company verification - org number not matched: ' . $orgNumber . + ' in ' . $countryIso . ' (found ' . count($companies) . ' companies)', + 2 + ); + return null; + } + + /** + * Extract organization number from address fields + * Checks various PrestaShop address fields where org numbers might be stored + * + * @param Address $address PrestaShop address object + * @param string $countryIso Country ISO code for context-aware extraction + * @return string Organization number or empty string + */ + public function extractOrgNumberFromAddress($address, $countryIso) + { + if (!Validate::isLoadedObject($address)) { + return ''; + } + + $countryIso = strtoupper(trim($countryIso)); + + // Priority 1: dni field (commonly used in ES, PT, IT for fiscal numbers like CIF/NIF) + if (!empty($address->dni)) { + $dni = trim($address->dni); + // Validate it looks like an org number (alphanumeric, reasonable length) + if (preg_match('/^[A-Z0-9\-]{5,20}$/i', $dni)) { + PrestaShopLogger::addLog( + 'TwoPayment: Found org number in dni field: ' . $dni . ' for ' . $countryIso, + 1 + ); + return $dni; + } + } + + // Priority 2: vat_number field (if available in address) + if (property_exists($address, 'vat_number') && !empty($address->vat_number)) { + $vatNumber = trim($address->vat_number); + // VAT numbers often have a country prefix (e.g. GB123...). Only strip when it matches address country. + if (preg_match('/^([A-Z]{2})([A-Z0-9\-]{3,})$/i', $vatNumber, $matches)) { + $prefix = strtoupper($matches[1]); + if ($prefix === $countryIso) { + $vatNumber = $matches[2]; + } + } + if (preg_match('/^[A-Z0-9\-]{5,20}$/i', $vatNumber)) { + PrestaShopLogger::addLog( + 'TwoPayment: Found org number in vat_number field: ' . $vatNumber . ' for ' . $countryIso, + 1 + ); + return $vatNumber; + } + } + + // Priority 3: companyid field (if it was set previously) + if (property_exists($address, 'companyid') && !empty($address->companyid)) { + PrestaShopLogger::addLog( + 'TwoPayment: Found org number in companyid field: ' . $address->companyid . ' for ' . $countryIso, + 1 + ); + return trim($address->companyid); + } + + return ''; + } + + /** + * Extract organization number from Two company search result + * Handles various field naming conventions across different countries + * + * @param array $company Company data from Two API + * @return string Organization number or empty string + */ + private function extractOrganizationNumber($company) + { + // Primary: national_identifier object (most countries) + if (isset($company['national_identifier']) && is_array($company['national_identifier'])) { + $ni = $company['national_identifier']; + $orgNumber = $ni['id'] ?? $ni['value'] ?? $ni['organisationNumber'] ?? + $ni['organizationNumber'] ?? $ni['registration_number'] ?? + $ni['company_number'] ?? ''; + if (!empty($orgNumber)) { + return trim($orgNumber); + } + } + + // Fallback: Direct fields (commonly used in GB) + $directFields = ['registration_number', 'company_number', 'organization_number', 'organisation_number']; + foreach ($directFields as $field) { + if (isset($company[$field]) && !empty($company[$field])) { + return trim($company[$field]); + } + } + + return ''; + } } - diff --git a/upgrade/upgrade-2.3.0.php b/upgrade/upgrade-2.3.0.php new file mode 100644 index 0000000..675fdaa --- /dev/null +++ b/upgrade/upgrade-2.3.0.php @@ -0,0 +1,99 @@ +id + ); + } + + // Add column to twopayment table to store term type per order + $column_name = 'two_payment_term_type'; + $column_exists = Db::getInstance()->getValue( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '" . _DB_NAME_ . "' + AND TABLE_NAME = '" . _DB_PREFIX_ . "twopayment' + AND COLUMN_NAME = '" . $column_name . "'" + ); + + if (!$column_exists) { + $query = "ALTER TABLE `" . _DB_PREFIX_ . "twopayment` + ADD `two_payment_term_type` VARCHAR(20) DEFAULT 'STANDARD' + AFTER `two_day_on_invoice`"; + + if (!Db::getInstance()->execute($query)) { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.3.0: Failed to add column ' . $column_name, + 3, + null, + 'Module', + $module->id + ); + return false; + } + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.3.0: Successfully added column ' . $column_name, + 1, + null, + 'Module', + $module->id + ); + } else { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.3.0: Column ' . $column_name . ' already exists, skipping', + 1, + null, + 'Module', + $module->id + ); + } + + // Update existing orders to have STANDARD type (for historical data consistency) + $update_query = "UPDATE `" . _DB_PREFIX_ . "twopayment` + SET `two_payment_term_type` = 'STANDARD' + WHERE `two_payment_term_type` IS NULL OR `two_payment_term_type` = ''"; + + if (Db::getInstance()->execute($update_query)) { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.3.0: Updated existing orders to STANDARD term type', + 1, + null, + 'Module', + $module->id + ); + } + + PrestaShopLogger::addLog( + 'TwoPayment: Successfully upgraded to version 2.3.0 - EOM payment terms feature added', + 1, + null, + 'Module', + $module->id + ); + + return true; +} + diff --git a/upgrade/upgrade-2.3.1.php b/upgrade/upgrade-2.3.1.php new file mode 100644 index 0000000..1415588 --- /dev/null +++ b/upgrade/upgrade-2.3.1.php @@ -0,0 +1,32 @@ +id + ); + + return true; +} diff --git a/upgrade/upgrade-2.3.2.php b/upgrade/upgrade-2.3.2.php new file mode 100644 index 0000000..6c6ba7d --- /dev/null +++ b/upgrade/upgrade-2.3.2.php @@ -0,0 +1,33 @@ +id + ); + + return true; +} diff --git a/upgrade/upgrade-2.4.0.php b/upgrade/upgrade-2.4.0.php new file mode 100644 index 0000000..5b238e8 --- /dev/null +++ b/upgrade/upgrade-2.4.0.php @@ -0,0 +1,137 @@ +getValue( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '" . _DB_NAME_ . "' AND TABLE_NAME = '" . pSQL($table_name) . "'" + ); + + if (!$exists) { + $query = '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;'; + + if (!Db::getInstance()->execute($query)) { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.4.0: Failed to create table ' . $table_name, + 3, + null, + 'Module', + $module->id + ); + return false; + } + + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.4.0: Created table ' . $table_name, + 1, + null, + 'Module', + $module->id + ); + } + + $columns_to_add = array( + 'cart_snapshot_hash' => "ALTER TABLE `" . _DB_PREFIX_ . "twopayment_attempt` ADD `cart_snapshot_hash` VARCHAR(64) NULL AFTER `two_invoice_id`", + 'order_create_idempotency_key' => "ALTER TABLE `" . _DB_PREFIX_ . "twopayment_attempt` ADD `order_create_idempotency_key` VARCHAR(128) NULL AFTER `cart_snapshot_hash`", + ); + + foreach ($columns_to_add as $column_name => $query) { + $column_exists = (int)Db::getInstance()->getValue( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = '" . _DB_NAME_ . "' + AND TABLE_NAME = '" . pSQL($table_name) . "' + AND COLUMN_NAME = '" . pSQL($column_name) . "'" + ); + + if (!$column_exists) { + if (!Db::getInstance()->execute($query)) { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.4.0: Failed to add column ' . $column_name, + 3, + null, + 'Module', + $module->id + ); + return false; + } + } + } + + $indexes_to_add = array( + 'idx_attempt_updated_at' => "ALTER TABLE `" . _DB_PREFIX_ . "twopayment_attempt` ADD INDEX `idx_attempt_updated_at` (`updated_at`)", + ); + + foreach ($indexes_to_add as $index_name => $query) { + $index_exists = (int)Db::getInstance()->getValue( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = '" . _DB_NAME_ . "' + AND TABLE_NAME = '" . pSQL($table_name) . "' + AND INDEX_NAME = '" . pSQL($index_name) . "'" + ); + + if (!$index_exists) { + if (!Db::getInstance()->execute($query)) { + PrestaShopLogger::addLog( + 'TwoPayment Upgrade 2.4.0: Failed to add index ' . $index_name, + 3, + null, + 'Module', + $module->id + ); + return false; + } + } + } + + PrestaShopLogger::addLog( + 'TwoPayment: Successfully upgraded to version 2.4.0 - Provider-first checkout flow with snapshot/idempotency hardening enabled', + 1, + null, + 'Module', + $module->id + ); + + return true; +} diff --git a/views/css/two.css b/views/css/two.css index 433179a..e180ab5 100644 --- a/views/css/two.css +++ b/views/css/two.css @@ -787,6 +787,14 @@ font-weight: 600; } +/* Admin order view payment terms must stay visible (separate from checkout animation class) */ +.two-admin-payment-terms { + color: #059669; + font-weight: 600; + opacity: 1; + transform: none; +} + /* Order State Styles */ .two-order-state { display: inline-block; @@ -945,6 +953,22 @@ gap: 6px; } +/* Payment Terms Badge for EOM */ +.two-term-type-badge { + display: inline-block; + background: #3b82f6; + color: white; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + margin-left: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + vertical-align: middle; + cursor: help; +} + /* Action Cards */ .two-actions-grid { display: flex; @@ -1116,4 +1140,4 @@ .payment-option.two-loading, .payment-option.two-loading::after { display: none !important; -} \ No newline at end of file +} diff --git a/views/js/modules/TwoCheckoutManager.js b/views/js/modules/TwoCheckoutManager.js index 9853a30..2f15c80 100644 --- a/views/js/modules/TwoCheckoutManager.js +++ b/views/js/modules/TwoCheckoutManager.js @@ -29,6 +29,22 @@ class TwoCheckoutManager { this.init(); } + + t(key, fallback) { + if (window.twopayment && window.twopayment.i18n && window.twopayment.i18n[key]) { + return window.twopayment.i18n[key]; + } + return fallback; + } + + escapeHtml(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } /** * Initialize the checkout manager @@ -238,17 +254,11 @@ class TwoCheckoutManager { this._accountTypeListenerAdded = true; accountTypeField.addEventListener('change', () => { const value = accountTypeField.value; - const wasBusiness = this.isBusinessAccount; this.isBusinessAccount = (value === 'business'); try { sessionStorage.setItem('two_account_type', value); } catch (e) {} - // Re-init company search accordingly - if (this.config.companySearchEnabled) { - if (this.isBusinessAccount && !this.companySearch) { - this.initializeCompanySearch(); - } else if (!this.isBusinessAccount && this.companySearch && this.companySearch.destroy) { - this.companySearch.destroy(); - this.companySearch = null; - } + // Keep company search available on address forms for reliable company selection. + if (this.config.companySearchEnabled && !this.companySearch) { + this.initializeCompanySearch(); } }); } @@ -299,35 +309,25 @@ class TwoCheckoutManager { // Method 3: Listen for form submissions and payment confirmation attempts document.addEventListener('click', (event) => { - const confirmationSelectors = [ - '#payment-confirmation button', - '.payment-confirmation button', - 'button[name="confirmDeliveryOption"]', - 'button[type="submit"][form*="payment"]', - '.checkout button[type="submit"]', - '.btn-primary[type="submit"]', - 'button.btn[name*="confirm"]' - ]; - - if (confirmationSelectors.some(selector => event.target.matches(selector))) { + if (this.isPaymentConfirmationButton(event.target)) { this.handlePaymentConfirmation(event); } }); // Method 4: Enhanced form submission listener (catch-all for different themes) document.addEventListener('submit', (event) => { - // Check if this is a payment/checkout form const form = event.target; - if (form && (form.action.includes('payment') || - form.action.includes('checkout') || - form.action.includes('order') || - form.querySelector('input[name*="payment"]'))) { + if (this.isPaymentConfirmationForm(form)) { this.handlePaymentConfirmation(event); } }); // Method 5: Periodic check for Two payment selection (fallback for complex themes) this._selectionCheckInterval = setInterval(() => { + this.detectCheckoutStep(); + if (this.currentStep !== 'payment') { + return; + } if (this.isTwoPaymentSelected() && this.config.orderIntentEnabled) { // Only trigger if we haven't processed this selection recently AND we don't have a result yet const hasResult = this.orderIntent && this.orderIntent.lastResult; @@ -343,7 +343,7 @@ class TwoCheckoutManager { } } } - }, 1000); + }, 3000); } /** @@ -428,17 +428,37 @@ class TwoCheckoutManager { } } - // Strategy 4: Check for Two payment in URL or form action (for some themes) - const forms = document.querySelectorAll('form'); - for (const form of forms) { - if (form.action && form.action.includes('twopayment')) { - return true; - } - } - return false; } - + + isPaymentConfirmationButton(target) { + if (!(target instanceof Element)) { + return false; + } + + const button = target.closest('button[type="submit"], input[type="submit"]'); + if (!button) { + return false; + } + + // Keep interception strictly scoped to payment confirmation area. + return !!( + button.closest('#payment-confirmation') || + button.closest('.payment-confirmation') + ); + } + + isPaymentConfirmationForm(form) { + if (!(form instanceof HTMLFormElement)) { + return false; + } + + return !!( + form.closest('#payment-confirmation') || + form.closest('.payment-confirmation') + ); + } + /** * Handle Two payment selection specifically */ @@ -511,7 +531,7 @@ class TwoCheckoutManager { this.orderIntent.checkOrderIntent().then(result => { this.handleOrderIntentResult(result); }).catch(error => { - this.handleOrderIntentError(error.message || 'Order intent check failed'); + this.showOrderIntentError(error.message || this.t('order_intent_check_failed', 'Order intent check failed')); }); } @@ -520,19 +540,47 @@ class TwoCheckoutManager { */ handleOrderIntentResult(result) { if (!result.success) { - // If order intent was skipped (e.g., missing company data), show a gentle prompt when account type is disabled - const err = (result && result.error) ? String(result.error).toLowerCase() : ''; + // ENHANCED: Handle specific company status codes from backend + const status = result.status || ''; + const err = (result && result.error) ? String(result.error) : ''; + const errLower = err.toLowerCase(); const useAccountType = !!(window.twopayment && String(window.twopayment.use_account_type) === '1'); - if (err.includes('skipped') && !useAccountType) { + + // Handle specific status codes for clear user guidance + // 'no_company' = no company name entered at all + // 'incomplete_company' = company name exists but backend couldn't auto-resolve org number + if (status === 'no_company') { + this.showCompanyRequiredMessage(err, status); + return; + } + + if (status === 'incomplete_company') { + // Backend tried to auto-resolve but couldn't find a confident match + // Show message asking user to search and select their company + this.showCompanyRequiredMessage(err, status); + return; + } + + // Legacy: If order intent was skipped (frontend-side skip), show appropriate prompt + if (errLower.includes('skipped_no_company') && !useAccountType) { + this.showCompanyRequiredMessage(err, 'no_company'); + return; + } + + if (errLower.includes('skipped') && !useAccountType) { + // Generic skip - show company selection prompt const messageContainer = this.getOrCreateMessageContainer(); - const requiredMsg = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.select_company_to_use_two) || 'To pay with Two, please select your company from the search results so we can verify your business and offer invoice terms.'; + const requiredMsg = this.t( + 'select_company_to_use_two', + 'To pay with Two, go back to your billing address and search for your company name. Select your company from the results to verify your business.' + ); const messageElement = messageContainer.querySelector('.two-payment-message') || messageContainer; if (messageElement !== messageContainer) { - messageElement.innerHTML = requiredMsg; + messageElement.textContent = requiredMsg; } else { messageContainer.innerHTML = ` -

${(window.twopayment && window.twopayment.i18n && window.twopayment.i18n.action_required_title) || 'Action Required'}

-

${requiredMsg}

+

${this.escapeHtml(this.t('action_required_title', 'Action Required'))}

+

${this.escapeHtml(requiredMsg)}

`; } messageContainer.classList.remove('approved', 'loading'); @@ -541,7 +589,7 @@ class TwoCheckoutManager { this.hideLoadingOverlay(); return; } - this.showOrderIntentError(result.error || 'Order intent check failed'); + this.showOrderIntentError(result.error || this.t('order_intent_check_failed', 'Order intent check failed')); return; } @@ -552,14 +600,14 @@ class TwoCheckoutManager { // Build company-aware message for display (translated) const companyName = this.getSelectedCompanyName(); if (result.approved) { - let approvedMsg = result.message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_approved_message) || 'Payment approved! Choose your payment terms below.'); + let approvedMsg = result.message || this.t('payment_approved_message', 'Payment approved! Choose your payment terms below.'); if (companyName && window.twopayment && window.twopayment.i18n && window.twopayment.i18n.invoice_likely_accepted_for) { approvedMsg = window.twopayment.i18n.invoice_likely_accepted_for.replace('%s', companyName); } this.showOrderIntentApproval(approvedMsg); } else { // For declined results, also check if the decline reason should be treated as an error - const baseDecline = result.message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_not_available_message) || 'Two payment is not available for this order.'); + const baseDecline = result.message || this.t('payment_not_available_message', 'Two payment is not available for this order.'); let declineMessage = baseDecline; if (companyName && window.twopayment && window.twopayment.i18n && window.twopayment.i18n.invoice_cannot_be_approved_for) { declineMessage = window.twopayment.i18n.invoice_cannot_be_approved_for.replace('%s', companyName); @@ -606,6 +654,60 @@ class TwoCheckoutManager { messageLower.includes('please provide') || messageLower.includes('missing'); } + + /** + * Show company required message with clear guidance (theme-independent) + * @param {string} message - The error message from backend + * @param {string} status - The status code: 'no_company' or 'incomplete_company' + */ + showCompanyRequiredMessage(message, status) { + const messageContainer = this.getOrCreateMessageContainer(); + const actionTitle = this.t('action_required_title', 'Action Required'); + + // Determine help text based on status + let helpText = ''; + if (status === 'no_company') { + helpText = this.t( + 'company_name_required', + 'To pay with Two, go back to your billing address and enter your company name in the Company field.' + ); + } else if (status === 'incomplete_company') { + // IMPROVED: When auto-resolution failed, give clearer guidance + // The backend tried to find the company but couldn't get a confident match + helpText = this.t( + 'select_company_to_use_two', + 'To pay with Two, go back to your billing address and search for your company name. Select your company from the search results to verify your business.' + ); + } + + // Build the message UI + const messageElement = messageContainer.querySelector('.two-payment-message') || messageContainer; + if (messageElement !== messageContainer) { + messageElement.textContent = message || helpText; + } else { + // For incomplete_company, show a more informative message + const displayTitle = status === 'incomplete_company' + ? this.t('company_verification_needed', 'Company Verification Needed') + : actionTitle; + const displayMessage = message || helpText; + + messageContainer.innerHTML = ` +

${this.escapeHtml(displayTitle)}

+

${this.escapeHtml(displayMessage)}

+ ${helpText && message !== helpText ? `

${this.escapeHtml(helpText)}

` : ''} + `; + } + + // Apply styling + messageContainer.classList.remove('approved', 'loading', 'declined'); + messageContainer.classList.add('show', 'action-required'); + messageContainer.style.display = 'block'; + + // Hide loading overlay + this.hideLoadingOverlay(); + + // Don't show payment terms - company verification required first + } /** * Show order intent loading state (theme-independent) @@ -622,7 +724,7 @@ class TwoCheckoutManager { overlay.innerHTML = `
- ${(window.twopayment && window.twopayment.i18n && window.twopayment.i18n.checking_eligibility) || 'Checking Two payment eligibility...'} + ${this.t('checking_eligibility', 'Checking Two payment eligibility...')}
`; parent.appendChild(overlay); @@ -648,12 +750,13 @@ class TwoCheckoutManager { // Update the payment info section with success message const messageElement = messageContainer.querySelector('.two-payment-message') || messageContainer; + const approvedMessage = message || this.t('payment_approved_message', 'Payment approved! Choose your payment terms below.'); if (messageElement !== messageContainer) { - messageElement.innerHTML = message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_approved_message) || 'Payment approved! Choose your payment terms below.'); + messageElement.textContent = approvedMessage; } else { messageContainer.innerHTML = ` -

${(window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_approved_title) || 'Payment Approved'}

-

${message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_approved_message) || 'Payment approved! Choose your payment terms below.')}

+

${this.escapeHtml(this.t('payment_approved_title', 'Payment Approved'))}

+

${this.escapeHtml(approvedMessage)}

`; } @@ -679,12 +782,13 @@ class TwoCheckoutManager { // Update the payment info section with decline message const messageElement = messageContainer.querySelector('.two-payment-message') || messageContainer; + const declineMessage = message || this.t('payment_not_available_message', 'Two payment is not available for this order.'); if (messageElement !== messageContainer) { - messageElement.innerHTML = message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_not_available_message) || 'Two payment is not available for this order.'); + messageElement.textContent = declineMessage; } else { messageContainer.innerHTML = ` -

${(window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_not_available_title) || 'Payment Not Available'}

-

${message || ((window.twopayment && window.twopayment.i18n && window.twopayment.i18n.payment_not_available_message) || 'Two payment is not available for this order.')}

+

${this.escapeHtml(this.t('payment_not_available_title', 'Payment Not Available'))}

+

${this.escapeHtml(declineMessage)}

`; } @@ -700,21 +804,38 @@ class TwoCheckoutManager { /** * Show order intent error (theme-independent) + * SMART: If company data is missing, show company-specific message instead of generic error */ showOrderIntentError(error) { - // Convert technical error messages to user-friendly ones - const userFriendlyError = this.getUserFriendlyErrorMessage(error); + // SMART CHECK: Before showing generic error, check if company data is actually missing + // This handles the common case where error is shown due to missing company selection + const companyMissing = this.isCompanyDataMissing(); + + let userFriendlyError; + if (companyMissing) { + // Company data is incomplete - show specific guidance + userFriendlyError = this.t( + 'select_company_to_use_two', + 'To pay with Two, go back to your billing address and search for your company name. Select your company from the results to verify your business.' + ); + } else { + // Company data looks complete - show generic error + userFriendlyError = this.t( + 'generic_error', + 'There was an issue processing your Two payment request. Please try again or choose another payment method.' + ); + } const messageContainer = this.getOrCreateMessageContainer(); // Update the payment info section with error message const messageElement = messageContainer.querySelector('.two-payment-message') || messageContainer; if (messageElement !== messageContainer) { - messageElement.innerHTML = userFriendlyError; + messageElement.textContent = userFriendlyError; } else { messageContainer.innerHTML = ` -

${(window.twopayment && window.twopayment.i18n && window.twopayment.i18n.action_required_title) || 'Action Required'}

-

${userFriendlyError}

+

${this.escapeHtml(this.t('action_required_title', 'Action Required'))}

+

${this.escapeHtml(userFriendlyError)}

`; } @@ -727,6 +848,32 @@ class TwoCheckoutManager { this.clearLoadingState(); this.isLoadingUIShown = false; } + + /** + * Check if company data is missing (org number not selected) + * @returns {boolean} True if company org number is missing + */ + isCompanyDataMissing() { + let orgNumber = ''; + + // Check companyid form field + const companyIdField = document.querySelector("input[name='companyid']"); + if (companyIdField && companyIdField.value) { + orgNumber = companyIdField.value.trim(); + } + + // Also check cookie as fallback + if (!orgNumber) { + try { + const cookieMatch = document.cookie.match(/two_company_id=([^;]+)/); + if (cookieMatch) { + orgNumber = decodeURIComponent(cookieMatch[1]).trim(); + } + } catch (e) { /* ignore */ } + } + + return !orgNumber; + } /** * Convert technical error messages to user-friendly ones @@ -734,39 +881,94 @@ class TwoCheckoutManager { getUserFriendlyErrorMessage(error) { // Handle specific error cases if (typeof error === 'string') { + const errorLower = error.toLowerCase(); + + // Case: Invalid phone number (from Two API validation) + if (errorLower.includes('invalid phone number') || + (errorLower.includes('phone_number') && errorLower.includes('value_error'))) { + return this.t( + 'invalid_phone_number', + 'The phone number in your billing address appears to be invalid. Please go back and ensure you have entered a valid phone number for your country.' + ); + } + // Case: "Company name is required for business accounts" - if (error.toLowerCase().includes('company name is required')) { - return 'Please enter your company name to continue with Two payment.'; + if (errorLower.includes('company name is required')) { + return this.t( + 'company_name_required', + 'To pay with Two, go back to your billing address and enter your company name in the Company field.' + ); } // Case: "Organization number is required" - if (error.toLowerCase().includes('organization number') && error.toLowerCase().includes('required')) { - return 'Please search and select a valid company to continue with Two payment.'; + if (errorLower.includes('organization number') && errorLower.includes('required')) { + return this.t( + 'select_company_to_use_two', + 'Go back to your billing address and search for your company name. Select your company from the results to verify your business.' + ); } // Case: "Invalid company information" - if (error.toLowerCase().includes('invalid company')) { - return 'The company information provided is not valid. Please search and select a valid company.'; + if (errorLower.includes('invalid company')) { + return this.t( + 'invalid_company', + 'The company information provided is not valid. Go back to your billing address and select a valid company from the search results.' + ); } // Case: "Company not found" - if (error.toLowerCase().includes('company not found')) { - return 'We could not find your company in our database. Please try searching with a different company name or contact support.'; + if (errorLower.includes('company not found')) { + return this.t( + 'company_not_found', + 'We could not find your company in our database. Please try searching with a different company name or contact support.' + ); + } + + // Case: Invalid email + if (errorLower.includes('invalid email') || + (errorLower.includes('email') && errorLower.includes('value_error'))) { + return this.t('invalid_email', 'The email address provided is invalid. Please check your email and try again.'); + } + + // Case: Invalid address + if (errorLower.includes('invalid address') || + (errorLower.includes('address') && errorLower.includes('value_error'))) { + return this.t( + 'invalid_address', + 'The address provided is invalid. Please go back and verify your billing address details.' + ); } // Case: "Credit check failed" or similar - if (error.toLowerCase().includes('credit') || error.toLowerCase().includes('not approved')) { - return 'Two payment is not available for this order. Please choose another payment method.'; + if (errorLower.includes('credit') || errorLower.includes('not approved')) { + return this.t( + 'credit_unavailable', + 'Two payment is not available for this order. Please choose another payment method.' + ); } // Case: API or network errors - if (error.toLowerCase().includes('network') || error.toLowerCase().includes('timeout') || error.toLowerCase().includes('api')) { - return 'There was a temporary issue verifying your payment. Please try again or choose another payment method.'; + if (errorLower.includes('network') || errorLower.includes('timeout') || errorLower.includes('api')) { + return this.t( + 'network_issue', + 'There was a temporary issue verifying your payment. Please try again or choose another payment method.' + ); + } + + // Case: General validation error + if (errorLower.includes('validation error') || errorLower.includes('value_error')) { + return this.t( + 'validation_error', + 'Some of the information provided is invalid. Please check your billing address details and try again.' + ); } } // Default fallback for unknown errors - return 'There was an issue processing your Two payment request. Please try again or choose another payment method.'; + return this.t( + 'generic_error', + 'There was an issue processing your Two payment request. Please try again or choose another payment method.' + ); } /** @@ -937,17 +1139,15 @@ class TwoCheckoutManager { const termsHtml = `
-

${window.twopayment?.i18n?.choose_payment_terms || 'Choose the Buy Now, Pay Later option that works best for you'}

-

${window.twopayment?.i18n?.payment_period_starts || 'Your payment period starts when your order is fulfilled, along with your invoice from Two'}

+

${this.t('choose_payment_terms', 'Choose the Buy Now, Pay Later option that works best for you')}

+

${this.t('payment_period_starts', 'Your payment period starts when your order is fulfilled')}

- ${window.twopayment?.i18n?.pay_in || 'Pay in'} - 30 - ${window.twopayment?.i18n?.days || 'days'} +
@@ -993,18 +1193,43 @@ class TwoCheckoutManager { // Get payment terms from admin configuration (passed via template) const availableTerms = this.config.available_payment_terms; const defaultTerm = this.config.default_payment_term; + const termType = this.config.payment_term_type || 'STANDARD'; // If no terms configured, don't show payment terms if (!availableTerms || !Array.isArray(availableTerms) || availableTerms.length === 0) { - console.warn('Two Payment: No payment terms configured'); return; } + // Update description based on term type + var termsDescription = document.querySelector('#two-terms-description'); + if (termsDescription) { + if (termType === 'EOM') { + var eomText = termsDescription.getAttribute('data-eom-text'); + if (eomText) { + termsDescription.textContent = eomText; + } + } else { + var standardText = termsDescription.getAttribute('data-standard-text'); + if (standardText) { + termsDescription.textContent = standardText; + } + } + } + // Create term options availableTerms.forEach((days, index) => { const termOption = document.createElement('div'); termOption.className = 'two-term-option'; - termOption.textContent = days; + + // Format display based on term type (EOM+X for End-of-Month, X for Standard) + if (termType === 'EOM') { + termOption.textContent = 'EOM+' + days; + termOption.title = this.t('end_of_month_plus_days', 'End of Month + %s days').replace('%s', days); + } else { + termOption.textContent = days; + termOption.title = days + ' ' + this.t('days', 'days'); + } + termOption.dataset.days = days; // Set default term: use configured default, or if only one term, make it active, or first term @@ -1024,9 +1249,25 @@ class TwoCheckoutManager { // Add active class to selected option termOption.classList.add('active'); - // Update selected days display + // Update selected term display if (selectedDays) { - selectedDays.textContent = days; + const payInText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.pay_in + ? window.twopayment.i18n.pay_in + : 'Pay in'; + const daysText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.days + ? window.twopayment.i18n.days + : 'days'; + const fromEndOfMonthText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.from_end_of_month + ? window.twopayment.i18n.from_end_of_month + : 'from end of month'; + + if (termType === 'EOM') { + // For EOM: Show "Pay in X days from end of month" format + selectedDays.textContent = payInText + ' ' + days + ' ' + daysText + ' ' + fromEndOfMonthText; + } else { + // For Standard: Show "Pay in 30 days" format + selectedDays.textContent = payInText + ' ' + days + ' ' + daysText; + } } // Persist selection in cookie via backend (10s timeout) @@ -1048,13 +1289,34 @@ class TwoCheckoutManager { termsSlider.appendChild(termOption); }); - // Set initial selected days based on the active term - if (selectedDays) { - const activeTerm = defaultTerm || (availableTerms.length === 1 ? availableTerms[0] : availableTerms[0]); - selectedDays.textContent = activeTerm; + // Set initial selected term display + const activeTerm = defaultTerm || (availableTerms.length === 1 ? availableTerms[0] : availableTerms[0]); + + if (selectedDays && activeTerm) { + const payInText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.pay_in + ? window.twopayment.i18n.pay_in + : 'Pay in'; + const daysText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.days + ? window.twopayment.i18n.days + : 'days'; + const fromEndOfMonthText = window.twopayment && window.twopayment.i18n && window.twopayment.i18n.from_end_of_month + ? window.twopayment.i18n.from_end_of_month + : 'from end of month'; + + if (termType === 'EOM') { + // For EOM: Show "Pay in X days from end of month" format + selectedDays.textContent = payInText + ' ' + activeTerm + ' ' + daysText + ' ' + fromEndOfMonthText; + } else { + // For Standard: Show "Pay in 30 days" format + selectedDays.textContent = payInText + ' ' + activeTerm + ' ' + daysText; + } } } - + + /** + * Update payment terms description based on term type + * Separated for reusability and early initialization + */ /** * Save order intent result to server for server-side validation * Prevents bypassing client-side blocking @@ -1119,12 +1381,23 @@ class TwoCheckoutManager { this.twoPaymentRadio.disabled = true; } } + + enableTwoPayment() { + if (this.twoPaymentRadio) { + this.twoPaymentRadio.disabled = false; + } + } /** * Setup mutation observer for dynamic content (theme-independent) */ setupMutationObserver() { - const observer = new MutationObserver((mutations) => { + if (this._mutationObserver) { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } + + this._mutationObserver = new MutationObserver((mutations) => { let shouldReinitialize = false; mutations.forEach((mutation) => { @@ -1153,7 +1426,7 @@ class TwoCheckoutManager { } }); - observer.observe(document.body, { + this._mutationObserver.observe(document.body, { childList: true, subtree: true }); @@ -1221,14 +1494,16 @@ class TwoCheckoutManager { this.companySearch.destroy(); this.companySearch = null; } - if (this.isBusinessAccount) { - this.initializeCompanySearch(); - } - // Clear cached intent state when address is edited so a new selection can trigger intent - if (this.orderIntent && this.orderIntent.reset) { - this.orderIntent.reset(); - } + this.initializeCompanySearch(); + } + + // Clear cached intent state when address is edited so a new selection can trigger intent + if (this.orderIntent && this.orderIntent.reset) { + this.orderIntent.reset(); } + this.clearOrderIntentUI(); + this.clearOrderIntentResultFromServer(); + this.enableTwoPayment(); // Phone validation removed - Two API handles validation } @@ -1291,6 +1566,11 @@ class TwoCheckoutManager { * Handle payment confirmation */ handlePaymentConfirmation(event) { + this.detectCheckoutStep(); + if (this.currentStep !== 'payment') { + return; + } + if (this.isTwoPaymentSelected() && this.orderIntent && this.config.orderIntentEnabled) { // If processing or no result yet, block and show loading if (this.orderIntent.isProcessing || !this.orderIntent.lastResult) { @@ -1306,7 +1586,7 @@ class TwoCheckoutManager { if (event && typeof event.preventDefault === 'function') { event.preventDefault(); } - const msg = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.approval_required) || 'Payment approval required before proceeding'; + const msg = this.t('approval_required', 'Payment approval required before proceeding'); this.showOrderIntentError(msg); } } @@ -1320,7 +1600,7 @@ class TwoCheckoutManager { this.initializeFieldValidation(); // Initialize company search for address step - if (this.config.companySearchEnabled && this.currentStep === 'address' && this.isBusinessAccount) { + if (this.config.companySearchEnabled && this.currentStep === 'address') { this.initializeCompanySearch(); } @@ -1428,6 +1708,11 @@ class TwoCheckoutManager { clearTimeout(this.reinitializeTimeout); this.reinitializeTimeout = null; } + + if (this._mutationObserver) { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } if (this._intentRetryTimeout) { clearTimeout(this._intentRetryTimeout); diff --git a/views/js/modules/TwoCompanySearch.js b/views/js/modules/TwoCompanySearch.js index da04089..feb75bd 100644 --- a/views/js/modules/TwoCompanySearch.js +++ b/views/js/modules/TwoCompanySearch.js @@ -30,6 +30,9 @@ class TwoCompanySearch { } this.createOrganizationField(); + this.clearStaleOrganizationSelection(); + this.setupCompanyInputSync(); + this.setupAddressIdentifierSync(); this.setupAutocomplete(); this.setupCountryChangeListener(); this.isInitialized = true; @@ -48,6 +51,123 @@ class TwoCompanySearch { this.organizationField = orgField; } + + normalizeCompanyName(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); + } + + buildPublicApiBeforeSend() { + return function (xhr) { + const blockedHeaders = { + 'authorization': true, + 'proxy-authorization': true, + 'x-api-key': true + }; + const originalSetRequestHeader = xhr && xhr.setRequestHeader ? xhr.setRequestHeader.bind(xhr) : null; + if (!originalSetRequestHeader) { + return; + } + xhr.setRequestHeader = function (name, value) { + const normalized = String(name || '').toLowerCase(); + if (blockedHeaders[normalized]) { + return; + } + originalSetRequestHeader(name, value); + }; + }; + } + + clearStaleOrganizationSelection() { + if (!this.companyField || !this.organizationField) { + return; + } + + const company = String(this.companyField.val() || '').trim(); + const orgNumber = String(this.organizationField.val() || '').trim(); + const taggedCompany = String(this.organizationField.attr('data-two-company-name') || '').trim(); + + if (!orgNumber) { + return; + } + + if (!company) { + this.organizationField.val(''); + this.organizationField.removeAttr('data-two-company-name'); + return; + } + + // If companyid exists but has no selection marker, treat it as stale after address/form re-renders. + if (!taggedCompany) { + this.organizationField.val(''); + return; + } + + if (this.normalizeCompanyName(company) !== this.normalizeCompanyName(taggedCompany)) { + this.organizationField.val(''); + this.organizationField.removeAttr('data-two-company-name'); + } + } + + setupCompanyInputSync() { + if (!this.companyField || this.companyField.length === 0) { + return; + } + + this.companyField.off('.twoCompanySync'); + this.companyField.on('input.twoCompanySync change.twoCompanySync', () => { + this.clearStaleOrganizationSelection(); + }); + } + + setupAddressIdentifierSync() { + if (!this.companyField || this.companyField.length === 0) { + return; + } + + const form = this.companyField.closest('form'); + if (!form || form.length === 0) { + return; + } + + form.off('submit.twoAddressIdentifierSync'); + form.on('submit.twoAddressIdentifierSync', () => { + this.syncOrganizationToAddressIdentifiers(); + }); + } + + syncOrganizationToAddressIdentifiers() { + if (!this.organizationField || this.organizationField.length === 0) { + return; + } + + let orgNumber = String(this.organizationField.val() || '').trim(); + const dniField = $("input[name='dni']"); + const vatField = $("input[name='vat_number']"); + + const dniValue = dniField.length > 0 ? String(dniField.val() || '').trim() : ''; + const vatValue = vatField.length > 0 ? String(vatField.val() || '').trim() : ''; + + // If user already filled DNI manually, reuse it as fallback org number for Two flow. + if (!orgNumber && dniValue) { + orgNumber = dniValue; + this.organizationField.val(orgNumber); + if (this.companyField && this.companyField.length > 0) { + this.organizationField.attr('data-two-company-name', this.companyField.val() || ''); + } + } + + if (!orgNumber) { + return; + } + + if (dniField.length > 0 && !dniValue) { + dniField.val(orgNumber); + } + + if (vatField.length > 0 && !vatValue) { + vatField.val(orgNumber); + } + } /** * Setup jQuery UI Autocomplete for company search @@ -194,7 +314,10 @@ class TwoCompanySearch { $.ajax({ url: searchUrl, method: 'GET', + crossDomain: true, dataType: 'json', + xhrFields: { withCredentials: false }, + beforeSend: this.buildPublicApiBeforeSend(), timeout: 10000, success: (data) => { const companies = data.items || []; @@ -301,6 +424,25 @@ class TwoCompanySearch { if (!ui.item) { return false; } + + const triggerOrderIntentRecheck = () => { + try { + if ( + window.TwoCheckoutManager_Instance && + window.TwoCheckoutManager_Instance.isTwoPaymentSelected && + window.TwoCheckoutManager_Instance.isTwoPaymentSelected() + ) { + if (window.TwoCheckoutManager_Instance.orderIntent && window.TwoCheckoutManager_Instance.orderIntent.reset) { + window.TwoCheckoutManager_Instance.orderIntent.reset(); + } + if (window.TwoCheckoutManager_Instance.triggerOrderIntentForSelection) { + window.TwoCheckoutManager_Instance.triggerOrderIntentForSelection(); + } + } + } catch (e) { + // noop + } + }; // SIMPLE & RELIABLE: Direct field assignment like old tillit.js this.companyField.val(ui.item.value); @@ -308,6 +450,7 @@ class TwoCompanySearch { // Set organization number immediately if available if (ui.item.organization_number) { this.organizationField.val(ui.item.organization_number); + this.organizationField.attr('data-two-company-name', ui.item.value); // Persist for reliability across steps this.persistCompanyToCookie({ @@ -320,8 +463,17 @@ class TwoCompanySearch { if (dniField.length > 0) { dniField.val(ui.item.organization_number); } + + const vatField = $("input[name='vat_number']"); + if (vatField.length > 0) { + vatField.val(ui.item.organization_number); + } } - + + // For some countries (e.g. GB), org number may only be present in company details. + // Defer order-intent trigger until details lookup completes when org number is missing. + const shouldDeferIntentTrigger = !!ui.item.lookup_id && !ui.item.organization_number; + // Optional: Fetch additional details for address auto-fill if lookup_id is available if (ui.item.lookup_id) { this.fetchCompanyDetails(ui.item.lookup_id) @@ -330,24 +482,20 @@ class TwoCompanySearch { }) .catch(error => { // Silently fail - address auto-fill is not critical + }) + .finally(() => { + if (shouldDeferIntentTrigger) { + triggerOrderIntentRecheck(); + } }); } // Country change has been resolved by a fresh company selection try { sessionStorage.removeItem('two_country_changed'); } catch (e) {} - // If user already selected Two payment, re-run order intent with new company - try { - if (window.TwoCheckoutManager_Instance && window.TwoCheckoutManager_Instance.isTwoPaymentSelected && window.TwoCheckoutManager_Instance.isTwoPaymentSelected()) { - if (window.TwoCheckoutManager_Instance.orderIntent && window.TwoCheckoutManager_Instance.orderIntent.reset) { - window.TwoCheckoutManager_Instance.orderIntent.reset(); - } - if (window.TwoCheckoutManager_Instance.triggerOrderIntentForSelection) { - window.TwoCheckoutManager_Instance.triggerOrderIntentForSelection(); - } - } - } catch (e) { - // noop + // If org number is already known from selection, run order intent immediately. + if (!shouldDeferIntentTrigger) { + triggerOrderIntentRecheck(); } return true; @@ -364,7 +512,10 @@ class TwoCompanySearch { $.ajax({ url: detailUrl, method: 'GET', + crossDomain: true, dataType: 'json', + xhrFields: { withCredentials: false }, + beforeSend: this.buildPublicApiBeforeSend(), timeout: 10000, success: resolve, error: (xhr, status, error) => { @@ -392,10 +543,15 @@ class TwoCompanySearch { const currentOrgNumber = this.organizationField.val(); if (!currentOrgNumber || currentOrgNumber !== natIdVal) { this.organizationField.val(natIdVal); + this.organizationField.attr('data-two-company-name', this.companyField ? this.companyField.val() : ''); const dniField = $("input[name='dni']"); if (dniField.length > 0) { dniField.val(natIdVal); } + const vatField = $("input[name='vat_number']"); + if (vatField.length > 0) { + vatField.val(natIdVal); + } // Persist to cookie so backend can use it during order placement this.persistCompanyToCookie({ company: this.companyField ? this.companyField.val() : '', @@ -477,7 +633,10 @@ class TwoCompanySearch { */ reset() { if (this.companyField) this.companyField.val(''); - if (this.organizationField) this.organizationField.val(''); + if (this.organizationField) { + this.organizationField.val(''); + this.organizationField.removeAttr('data-two-company-name'); + } } /** @@ -537,6 +696,7 @@ class TwoCompanySearch { } if (this.organizationField) { this.organizationField.val(''); + this.organizationField.removeAttr('data-two-company-name'); } // Recreate autocomplete to ensure new country is used immediately this.setupAutocomplete(); @@ -606,7 +766,8 @@ class TwoCompanySearch { token: window.twopayment.ajax_token, company: data.company, companyid: data.companyid, - country: this.getCurrentCountry() + country: this.getCurrentCountry(), + id_address: this.getCurrentAddressId() }, timeout: 10000 }); @@ -615,6 +776,49 @@ class TwoCompanySearch { } } + getCurrentAddressId() { + const checkedAddressSelectors = [ + "input[name='id_address_invoice']:checked", + "input[name='id_address_delivery']:checked" + ]; + for (const selector of checkedAddressSelectors) { + const field = document.querySelector(selector); + if (field && field.value) { + const parsed = parseInt(field.value, 10); + if (parsed > 0) { + return parsed; + } + } + } + + const addressForm = document.querySelector('.js-address-form form[data-id-address]'); + if (addressForm) { + const attrValue = addressForm.getAttribute('data-id-address'); + const parsed = parseInt(attrValue || '0', 10); + if (parsed > 0) { + return parsed; + } + } + + const selectors = [ + "input[name='id_address_invoice']", + "input[name='id_address_delivery']", + "input[name='id_address']" + ]; + + for (const selector of selectors) { + const field = document.querySelector(selector); + if (field && field.value) { + const parsed = parseInt(field.value, 10); + if (parsed > 0) { + return parsed; + } + } + } + + return 0; + } + /** * Check if module is available and initialized */ diff --git a/views/js/modules/TwoFieldValidation.js b/views/js/modules/TwoFieldValidation.js index 3837021..d405999 100644 --- a/views/js/modules/TwoFieldValidation.js +++ b/views/js/modules/TwoFieldValidation.js @@ -158,7 +158,8 @@ class TwoFieldValidation { showCompanyFieldError() { this.clearCompanyFieldError(); - const errorMessage = 'Company name is required for business accounts.'; + const errorMessage = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.company_name_required_business) || + 'Company name is required for business accounts.'; const errorElement = $(``); // Insert error message after the company field diff --git a/views/js/modules/TwoOrderIntent.js b/views/js/modules/TwoOrderIntent.js index 8e86f3c..17eb75e 100644 --- a/views/js/modules/TwoOrderIntent.js +++ b/views/js/modules/TwoOrderIntent.js @@ -17,6 +17,34 @@ class TwoOrderIntent { this.checkIntervalId = null; this.lastCompany = null; } + + t(key, fallback) { + if (window.twopayment && window.twopayment.i18n && window.twopayment.i18n[key]) { + return window.twopayment.i18n[key]; + } + return fallback; + } + + buildPublicApiBeforeSend() { + return function (xhr) { + const blockedHeaders = { + 'authorization': true, + 'proxy-authorization': true, + 'x-api-key': true + }; + const originalSetRequestHeader = xhr && xhr.setRequestHeader ? xhr.setRequestHeader.bind(xhr) : null; + if (!originalSetRequestHeader) { + return; + } + xhr.setRequestHeader = function (name, value) { + const normalized = String(name || '').toLowerCase(); + if (blockedHeaders[normalized]) { + return; + } + originalSetRequestHeader(name, value); + }; + }; + } shouldRunOrderIntent() { if (!this.config.enabled) return false; @@ -31,20 +59,30 @@ class TwoOrderIntent { } this.isProcessing = true; + // CRITICAL FIX: Always let the backend try to resolve company data + // The backend can check address fields (dni, vat_number) that the frontend can't see + // Backend will return appropriate status codes if company data is missing return this.collectFormData() .then(formData => { - const useAccountType = !!(window.twopayment && String(window.twopayment.use_account_type) === '1'); - if (!useAccountType) { - const hasCompany = !!(formData.company && String(formData.company).trim().length > 0); - const hasCompanyId = !!(formData.companyid && String(formData.companyid).trim().length > 0); - if (!hasCompany || !hasCompanyId) { - // Skip without calling server; UI will prompt - throw new Error('skipped'); - } - } + // Always proceed to backend - it will check: + // 1. Form data (company, companyid) + // 2. Session cookie + // 3. Address fields (dni, vat_number) and verify via Two API + // Backend returns status codes: 'no_company', 'incomplete_company' if needed return this.fetchOrderIntentPayload(formData); }) - .then(payload => this.callTwoOrderIntent(payload)) + .then(payload => { + const payloadCompany = ( + payload && + payload.buyer && + payload.buyer.company && + payload.buyer.company.company_name + ) ? String(payload.buyer.company.company_name).trim() : ''; + if (payloadCompany) { + this.lastCompany = payloadCompany; + } + return this.callTwoOrderIntent(payload); + }) .then(result => this.processResult(result)) .catch(error => this.handleError(error)) .finally(() => { this.isProcessing = false; }); @@ -73,8 +111,9 @@ class TwoOrderIntent { try { sessionStorage.removeItem('two_country_changed'); } catch (e) {} } - // If fields are empty (e.g., only payment step visible), try cookie fallback - if ((!company || !companyid) && window.twopayment && window.twopayment.order_intent_url && window.twopayment.ajax_token) { + // Only use cookie fallback when BOTH values are missing (e.g., payment step with no address form fields). + // If one value exists and the other is missing, keep form values as-is to avoid stale mixed company/companyid pairs. + if ((!company && !companyid) && window.twopayment && window.twopayment.order_intent_url && window.twopayment.ajax_token) { $.ajax({ url: window.twopayment.order_intent_url, type: 'POST', @@ -83,22 +122,28 @@ class TwoOrderIntent { timeout: 8000 }).done((res) => { if (res && res.success) { - formData.company = company || (res.company || ''); - formData.companyid = companyid || (res.companyid || ''); - // If stored company country mismatches address country or country changed, invalidate stored company + formData.company = (res.company || ''); + formData.companyid = (res.companyid || ''); + // If stored company country/address mismatches current address context, invalidate stored company const addressCountryIso = this.getCurrentAddressCountryISO(); const storedCountryMismatch = res.country && addressCountryIso && res.country.toUpperCase() !== addressCountryIso.toUpperCase(); - if (countryChanged || storedCountryMismatch) { + const currentAddressId = this.getCurrentAddressId(); + const storedAddressId = res.address_id ? parseInt(res.address_id, 10) : 0; + const storedAddressMismatch = storedAddressId > 0 && currentAddressId > 0 && storedAddressId !== currentAddressId; + if (countryChanged || storedCountryMismatch || storedAddressMismatch) { // DEBUG: Log country change details for troubleshooting - console.log('Two Order Intent: Country change detected.', { + console.log('Two Order Intent: Invalidating stored company context.', { countryChanged: countryChanged, storedCountryMismatch: storedCountryMismatch, + storedAddressMismatch: storedAddressMismatch, storedCompanyCountry: res.country, + storedAddressId: storedAddressId, + currentAddressId: currentAddressId, currentAddressCountry: addressCountryIso, invalidatingCompany: res.company }); - formData.company = company; // keep whatever is in the field (likely empty) - formData.companyid = companyid; + formData.company = ''; + formData.companyid = ''; } // Persist last company for messaging this.lastCompany = formData.company; @@ -106,17 +151,19 @@ class TwoOrderIntent { formData.company = company; formData.companyid = companyid; } - const addressDeliveryField = document.querySelector("input[name='id_address_delivery']"); - if (addressDeliveryField) { - formData.id_address_delivery = addressDeliveryField.value; + const selectedAddressId = this.getCurrentAddressId(); + if (selectedAddressId > 0) { + formData.id_address_invoice = selectedAddressId; + formData.id_address_delivery = selectedAddressId; } resolve(formData); }).fail(() => { formData.company = company; formData.companyid = companyid; - const addressDeliveryField = document.querySelector("input[name='id_address_delivery']"); - if (addressDeliveryField) { - formData.id_address_delivery = addressDeliveryField.value; + const selectedAddressId = this.getCurrentAddressId(); + if (selectedAddressId > 0) { + formData.id_address_invoice = selectedAddressId; + formData.id_address_delivery = selectedAddressId; } resolve(formData); }); @@ -125,9 +172,10 @@ class TwoOrderIntent { formData.company = company; formData.companyid = companyid; this.lastCompany = company; - const addressDeliveryField = document.querySelector("input[name='id_address_delivery']"); - if (addressDeliveryField) { - formData.id_address_delivery = addressDeliveryField.value; + const selectedAddressId = this.getCurrentAddressId(); + if (selectedAddressId > 0) { + formData.id_address_invoice = selectedAddressId; + formData.id_address_delivery = selectedAddressId; } resolve(formData); }); @@ -181,6 +229,49 @@ class TwoOrderIntent { return ''; } + getCurrentAddressId() { + const checkedAddressSelectors = [ + "input[name='id_address_invoice']:checked", + "input[name='id_address_delivery']:checked" + ]; + for (const selector of checkedAddressSelectors) { + const field = document.querySelector(selector); + if (field && field.value) { + const parsed = parseInt(field.value, 10); + if (parsed > 0) { + return parsed; + } + } + } + + const addressForm = document.querySelector('.js-address-form form[data-id-address]'); + if (addressForm) { + const attrValue = addressForm.getAttribute('data-id-address'); + const parsed = parseInt(attrValue || '0', 10); + if (parsed > 0) { + return parsed; + } + } + + const selectors = [ + "input[name='id_address_invoice']", + "input[name='id_address_delivery']", + "input[name='id_address']" + ]; + + for (const selector of selectors) { + const field = document.querySelector(selector); + if (field && field.value) { + const parsed = parseInt(field.value, 10); + if (parsed > 0) { + return parsed; + } + } + } + + return 0; + } + fetchOrderIntentPayload(formData) { return new Promise((resolve, reject) => { $.ajax({ @@ -210,9 +301,12 @@ class TwoOrderIntent { $.ajax({ url: (window.twopayment && window.twopayment.checkout_host ? window.twopayment.checkout_host : '') + '/v1/order_intent', type: 'POST', + crossDomain: true, dataType: 'json', contentType: 'application/json', data: JSON.stringify(payload), + xhrFields: { withCredentials: false }, + beforeSend: this.buildPublicApiBeforeSend(), timeout: 15000, success: (response) => { // Normalize to previous result shape @@ -232,12 +326,14 @@ class TwoOrderIntent { processResult(response) { if (!response || typeof response !== 'object') { - return { success: false, approved: false, message: 'Invalid response from server' }; + return { success: false, approved: false, message: this.t('invalid_response_from_server', 'Invalid response from server') }; } const result = { success: !!response.success, approved: !!response.approved, - message: response.message || (response.approved ? 'Your invoice with Two is likely to be accepted' : 'Your invoice with Two cannot be approved at this time'), + message: response.message || (response.approved + ? this.t('invoice_likely_accepted', 'Your invoice with Two is likely to be accepted') + : this.t('invoice_cannot_be_approved', 'Your invoice with Two cannot be approved at this time')), rawResponse: response.rawResponse || response }; const companyField = document.querySelector("input[name='company']"); @@ -246,9 +342,11 @@ class TwoOrderIntent { } // Inject company into message immediately to ensure UI gets the contextual string if (this.lastCompany && typeof this.lastCompany === 'string' && this.lastCompany.trim().length > 0) { + const approvedTemplate = this.t('invoice_likely_accepted_for', 'Your invoice with Two is likely to be accepted for %s'); + const declinedTemplate = this.t('invoice_cannot_be_approved_for', 'Your invoice with Two cannot be approved at this time for %s'); result.message = result.approved - ? `Your invoice with Two is likely to be accepted for ${this.lastCompany}` - : `Your invoice with Two cannot be approved at this time for ${this.lastCompany}`; + ? approvedTemplate.replace('%s', this.lastCompany) + : declinedTemplate.replace('%s', this.lastCompany); } this.lastResult = result; this.updateUI(result); @@ -256,32 +354,77 @@ class TwoOrderIntent { } getErrorMessage(errorString) { + // Default fallback message (uses i18n) + const defaultMessage = this.t( + 'invoice_declined', + 'Your invoice with Two cannot be approved at this time. Please select an alternative payment method.' + ); + if (!errorString) { - return 'Your invoice with Two cannot be approved at this time. Please select an alternative payment method.'; + return defaultMessage; } const error = ('' + errorString).toLowerCase(); + + // Phone number validation errors (priority - specific error type) + if (error.includes('invalid phone number') || + (error.includes('phone_number') && error.includes('value_error'))) { + return this.t( + 'invalid_phone_number', + 'The phone number in your billing address appears to be invalid. Please go back and ensure you have entered a valid phone number for your country.' + ); + } + + // Email validation errors + if (error.includes('invalid email') || + (error.includes('email') && error.includes('value_error'))) { + return this.t('invalid_email', 'The email address provided is invalid. Please check your email and try again.'); + } + + // Organization/company errors if (error.includes('organization_number') || error.includes('organization number')) { - return 'Company information is incomplete. Please ensure you have selected your company from the search results.'; + return this.t( + 'company_incomplete', + 'Company information is incomplete. Go back to your billing address and select your company from the search results.' + ); } - if (error.includes('validation error')) { - return 'Some required company information is missing. Please complete all required fields.'; + + // General validation errors + if (error.includes('validation error') || error.includes('value_error')) { + return this.t( + 'validation_error', + 'Some of the information provided is invalid. Please check your billing address details and try again.' + ); } + + // Invalid data errors if (error.includes('invalid')) { - return 'The company information provided is not valid. Please select your company from the search results.'; + return this.t( + 'invalid_company', + 'The company information provided is not valid. Go back to your billing address and select your company from the search results.' + ); } + + // Not found errors if (error.includes('not found') || error.includes('404')) { - return 'Company information could not be verified. Please select your company from the search results.'; + return this.t( + 'company_verify_failed', + 'Company information could not be verified. Go back to your billing address and select your company from the search results.' + ); } - return 'Your invoice with Two cannot be approved at this time. Please select an alternative payment method.'; + + return defaultMessage; } handleError(error) { - const isSkip = (error && typeof error.message === 'string' && error.message.toLowerCase().includes('skipped')); + // Backend provides clear status codes, so just pass through the error message + const message = this.getErrorMessage(error && error.message ? error.message : ''); + const result = { success: false, approved: false, - message: isSkip ? 'Order intent check skipped' : this.getErrorMessage(error && error.message ? error.message : ''), - error: error && error.message ? error.message : '' + message: message, + error: error && error.message ? error.message : '', + status: 'error' }; this.lastResult = result; this.updateUI(result); @@ -289,8 +432,8 @@ class TwoOrderIntent { } updateUI(result) { - const $twoPaymentOption = $('.payment-option').filter(function() { - return $(this).find('[data-module-name="twopayment"]').length > 0; + const $twoPaymentOption = $('.payment-option').filter((_, element) => { + return $(element).find('[data-module-name="twopayment"]').length > 0; }); if ($twoPaymentOption.length === 0) return; let $messageContainer = $twoPaymentOption.find('.two-order-intent-message'); @@ -301,10 +444,10 @@ class TwoOrderIntent { let messageText = result.message; if (this.lastCompany && typeof this.lastCompany === 'string' && this.lastCompany.trim().length > 0) { if (result.approved) { - const t = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.invoice_likely_accepted_for) || 'Your invoice with Two is likely to be accepted for %s'; + const t = this.t('invoice_likely_accepted_for', 'Your invoice with Two is likely to be accepted for %s'); messageText = t.replace('%s', this.lastCompany); } else { - const t = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.invoice_cannot_be_approved_for) || 'Your invoice with Two cannot be approved at this time for %s'; + const t = this.t('invoice_cannot_be_approved_for', 'Your invoice with Two cannot be approved at this time for %s'); messageText = t.replace('%s', this.lastCompany); } } @@ -312,7 +455,7 @@ class TwoOrderIntent { $messageContainer .removeClass('approved declined loading') .addClass(result.approved ? 'approved' : 'declined') - .html(messageText); + .text(messageText); if (result.approved) { $twoPaymentOption.removeClass('disabled'); $twoPaymentOption.find('input[type="radio"]').prop('disabled', false); @@ -355,9 +498,11 @@ class TwoOrderIntent { } showOrderPreventionMessage() { - const message = this.lastResult ? this.lastResult.message : 'Please resolve the payment issue before continuing.'; - const $twoPaymentOption = $('.payment-option').filter(function() { - return $(this).find('[data-module-name="twopayment"]').length > 0; + const message = this.lastResult + ? this.lastResult.message + : this.t('resolve_payment_issue_before_continuing', 'Please resolve the payment issue before continuing.'); + const $twoPaymentOption = $('.payment-option').filter((_, element) => { + return $(element).find('[data-module-name="twopayment"]').length > 0; }); $twoPaymentOption.addClass('pulse-highlight'); setTimeout(() => { $twoPaymentOption.removeClass('pulse-highlight'); }, 2000); @@ -371,32 +516,31 @@ class TwoOrderIntent { startMonitoring() { if (this.checkIntervalId) this.stopMonitoring(); this.checkIntervalId = setInterval(() => { - const $twoPaymentOption = $('.payment-option').filter(function() { - return $(this).find('[data-module-name="twopayment"]').length > 0; + const $twoPaymentOption = $('.payment-option').filter((_, element) => { + return $(element).find('[data-module-name="twopayment"]').length > 0; }); if ($twoPaymentOption.length > 0 && $twoPaymentOption.is(':visible')) { - // If account type is disabled and company data is missing, show gentle prompt instead of calling intent - const useAccountType = !!(window.twopayment && String(window.twopayment.use_account_type) === '1'); - if (!useAccountType) { - let countryChanged = false; - try { countryChanged = (sessionStorage.getItem('two_country_changed') === '1'); } catch (e) {} - const companyField = document.querySelector("input[name='company']"); - const companyIdField = document.querySelector("input[name='companyid']"); - const hasCompany = companyField && companyField.value && companyField.value.trim().length > 0; - const hasCompanyId = companyIdField && companyIdField.value && companyIdField.value.trim().length > 0; - if (countryChanged || !hasCompany || !hasCompanyId) { - // ADDITIONAL FIX: Clear country changed flag here as well when detected - if (countryChanged) { - try { sessionStorage.removeItem('two_country_changed'); } catch (e) {} - } - const $msg = $twoPaymentOption.find('.two-order-intent-message'); - if ($msg.length > 0) { - const t = (window.twopayment && window.twopayment.i18n && window.twopayment.i18n.select_company_to_use_two) || 'To pay with Two, please select your company from the search results so we can verify your business and offer invoice terms.'; - $msg.removeClass('approved declined loading').html(t).show(); - } - return; + // Check for country change - if country changed, user needs to re-select company + let countryChanged = false; + try { countryChanged = (sessionStorage.getItem('two_country_changed') === '1'); } catch (e) {} + + if (countryChanged) { + // Clear country changed flag + try { sessionStorage.removeItem('two_country_changed'); } catch (e) {} + const $msg = $twoPaymentOption.find('.two-order-intent-message'); + if ($msg.length > 0) { + const t = this.t( + 'select_company_to_use_two', + 'To pay with Two, go back to your billing address and search for your company name. Select your company from the results to verify your business.' + ); + $msg.removeClass('approved declined loading').text(t).show(); } + return; } + + // CRITICAL FIX: Always let the backend check for company data + // Backend can find org numbers in address fields (dni, vat_number) + // that the frontend can't see, and verify them via Two API this.checkOrderIntent(); } }, 5000); @@ -410,8 +554,12 @@ class TwoOrderIntent { } getLastResult() { return this.lastResult; } - reset() { this.lastResult = null; this.isProcessing = false; this.stopMonitoring(); } + reset() { + this.lastResult = null; + this.lastCompany = null; + this.isProcessing = false; + this.stopMonitoring(); + } } window.TwoOrderIntent = TwoOrderIntent; - diff --git a/views/js/twopayment.js b/views/js/twopayment.js index 87c0ef9..2641082 100644 --- a/views/js/twopayment.js +++ b/views/js/twopayment.js @@ -6,13 +6,72 @@ // CRITICAL FIX: Ensure jQuery is available before executing (function() { 'use strict'; - + let localFallbackAttempted = false; + + function getLocalJQueryCandidates() { + const candidates = []; + const base = ( + window.prestashop && + window.prestashop.urls && + window.prestashop.urls.base_url + ) ? window.prestashop.urls.base_url : '/'; + + const normalizedBase = base.endsWith('/') ? base : (base + '/'); + const versions = ['3.7.1', '3.6.0', '3.5.1', '2.2.4', '1.11.0']; + + versions.forEach(function(version) { + candidates.push(normalizedBase + 'js/jquery/jquery-' + version + '.min.js'); + candidates.push(normalizedBase + 'js/jquery/jquery-' + version + '.js'); + }); + + return candidates; + } + + function loadScriptSequentially(urls, done) { + if (!urls.length) { + done(false); + return; + } + + const url = urls.shift(); + const script = document.createElement('script'); + script.src = url; + script.async = false; + script.onload = function() { + done(true); + }; + script.onerror = function() { + loadScriptSequentially(urls, done); + }; + document.head.appendChild(script); + } + + function ensureLocalJQueryFallback(callback) { + if (localFallbackAttempted) { + callback(); + return; + } + + localFallbackAttempted = true; + loadScriptSequentially(getLocalJQueryCandidates(), function() { + callback(); + }); + } + // Wait for jQuery to be available (with timeout) function waitForJQuery(callback, maxAttempts = 50) { if (typeof jQuery !== 'undefined' && typeof $ !== 'undefined') { // jQuery is available, proceed callback(); } else if (maxAttempts > 0) { + if (maxAttempts === 25 && !localFallbackAttempted) { + ensureLocalJQueryFallback(function() { + setTimeout(function() { + waitForJQuery(callback, maxAttempts - 1); + }, 100); + }); + return; + } // jQuery not yet available, wait and retry setTimeout(function() { waitForJQuery(callback, maxAttempts - 1); @@ -42,15 +101,20 @@ // DEFENSIVE: Retry initialization if DOM isn't ready function initializeTwoPayment() { try { + if (window.TwoCheckoutManager_Instance && typeof window.TwoCheckoutManager_Instance.cleanup === 'function') { + window.TwoCheckoutManager_Instance.cleanup(); + } + // Initialize the checkout manager with configuration const checkoutManager = new TwoCheckoutManager({ companySearchEnabled: twopayment.company_name_search === '1', - orderIntentEnabled: twopayment.enable_order_intent === '1', + orderIntentEnabled: true, checkoutHost: twopayment.checkout_host, orderIntentUrl: twopayment.order_intent_url, ajaxToken: twopayment.ajax_token, available_payment_terms: twopayment.available_payment_terms || [30], - default_payment_term: twopayment.default_payment_term || 30 + default_payment_term: twopayment.default_payment_term || 30, + payment_term_type: twopayment.payment_term_type }); // Store global reference for modules @@ -63,9 +127,13 @@ setTimeout(() => { try { if (typeof TwoCheckoutManager !== 'undefined') { + if (window.TwoCheckoutManager_Instance && typeof window.TwoCheckoutManager_Instance.cleanup === 'function') { + window.TwoCheckoutManager_Instance.cleanup(); + } + const checkoutManager = new TwoCheckoutManager({ companySearchEnabled: twopayment.company_name_search === '1', - orderIntentEnabled: twopayment.enable_order_intent === '1', + orderIntentEnabled: true, checkoutHost: twopayment.checkout_host, orderIntentUrl: twopayment.order_intent_url, ajaxToken: twopayment.ajax_token, @@ -83,32 +151,12 @@ // Initialize immediately initializeTwoPayment(); - - // ADDITIONAL COMPATIBILITY: Listen for dynamic content changes (some themes load checkout content via AJAX) - if (typeof MutationObserver !== 'undefined') { - const observer = new MutationObserver((mutations) => { - let shouldReinit = false; - mutations.forEach((mutation) => { - if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { - for (let node of mutation.addedNodes) { - if (node.nodeType === 1 && - (node.querySelector && - (node.querySelector('.payment-options') || - node.querySelector('.js-address-form')))) { - shouldReinit = true; - break; - } - } - } - }); - - if (shouldReinit && typeof TwoCheckoutManager !== 'undefined' && !window.TwoCheckoutManager_Instance) { - setTimeout(initializeTwoPayment, 100); - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - } + + window.addEventListener('beforeunload', function() { + if (window.TwoCheckoutManager_Instance && typeof window.TwoCheckoutManager_Instance.cleanup === 'function') { + window.TwoCheckoutManager_Instance.cleanup(); + } + }); }); }); })(); diff --git a/views/templates/admin/configuration.tpl b/views/templates/admin/configuration.tpl index a00d5ed..8f377b2 100644 --- a/views/templates/admin/configuration.tpl +++ b/views/templates/admin/configuration.tpl @@ -10,6 +10,7 @@ {l s='General Settings' mod='twopayment'} {l s='Other Settings' mod='twopayment'} {l s='Order Status Settings' mod='twopayment'} + {l s='Plugin Information' mod='twopayment'}
@@ -47,6 +48,9 @@
{$renderTwoOrderStatusForm nofilter}
+
+ {$renderTwoPluginInfo nofilter} +
@@ -58,6 +62,34 @@ $('#two-tabs a').removeClass('active'); $(this).addClass('active'); }); + + // Payment Term Type - Dynamic show/hide of term options + // PHP 7.1+ compatible: Using ES5 syntax (no arrow functions) + function updatePaymentTermsVisibility() { + var termType = $('input[name="PS_TWO_PAYMENT_TERM_TYPE"]:checked').val(); + + if (termType === 'EOM') { + // EOM: Only show 30, 45, 60 day options + $('.two-term-standard').closest('.form-group').hide(); + $('.two-term-both').closest('.form-group').show(); + $('#two-payment-terms-desc-standard').hide(); + $('#two-payment-terms-desc-eom').show(); + } else { + // STANDARD: Show all options (7, 15, 20, 30, 45, 60, 90) + $('.two-term-standard').closest('.form-group').show(); + $('.two-term-both').closest('.form-group').show(); + $('#two-payment-terms-desc-standard').show(); + $('#two-payment-terms-desc-eom').hide(); + } + } + + // Run on page load + updatePaymentTermsVisibility(); + + // Run when term type changes + $('input[name="PS_TWO_PAYMENT_TERM_TYPE"]').on('change', function() { + updatePaymentTermsVisibility(); + }); }); {/literal} \ No newline at end of file diff --git a/views/templates/hook/displayAdminOrderLeft.tpl b/views/templates/hook/displayAdminOrderLeft.tpl index 2b9030f..4ebbb7d 100644 --- a/views/templates/hook/displayAdminOrderLeft.tpl +++ b/views/templates/hook/displayAdminOrderLeft.tpl @@ -16,19 +16,43 @@ {if $twopaymentdata.two_order_id}
{l s='Two Order ID' mod='twopayment'} - {$twopaymentdata.two_order_id} + {$twopaymentdata.two_order_id|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_order_reference}
{l s='Reference' mod='twopayment'} - {$twopaymentdata.two_order_reference} + {$twopaymentdata.two_order_reference|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_day_on_invoice}
{l s='Payment Terms' mod='twopayment'} - {$twopaymentdata.two_day_on_invoice} {l s='days' mod='twopayment'} + + {if $twopaymentdata.two_payment_term_type == 'EOM'} + {l s='End of Month' mod='twopayment'} + {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'} {l s='days' mod='twopayment'} + EOM + {else} + {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'} {l s='days' mod='twopayment'} + {/if} + +
+ {else} +
+ {l s='Payment Terms' mod='twopayment'} + {l s='Not recorded for this order' mod='twopayment'} +
+ {/if} + {if $twopaymentdata.two_order_state} +
+ {l s='Two State' mod='twopayment'} + {$twopaymentdata.two_order_state|escape:'html':'UTF-8'} +
+ {/if} + {if $twopaymentdata.two_order_status} +
+ {l s='Two Status' mod='twopayment'} + {$twopaymentdata.two_order_status|escape:'html':'UTF-8'}
{/if} @@ -36,21 +60,25 @@ {* Action Links Section *}
{if $twopaymentdata.two_order_id && $two_portal_url} - + {l s='View in Portal' mod='twopayment'} {/if} - {if $two_pdf_url} - + {if $two_invoice_actions_available && $two_pdf_url} + {l s='Download Invoice' mod='twopayment'} {/if} - {if $twopaymentdata.two_invoice_url} - + {if $two_invoice_actions_available && $twopaymentdata.two_invoice_url} + {l s='Invoice URL' mod='twopayment'} {/if} + {if !$two_invoice_actions_available} +
+ {l s='Invoice links become available after the Two order is fulfilled.' mod='twopayment'} +
+ {/if}
- diff --git a/views/templates/hook/displayAdminOrderTabContent.tpl b/views/templates/hook/displayAdminOrderTabContent.tpl index cfaf6c9..b7ae6db 100644 --- a/views/templates/hook/displayAdminOrderTabContent.tpl +++ b/views/templates/hook/displayAdminOrderTabContent.tpl @@ -19,19 +19,43 @@ {if $twopaymentdata.two_order_id}
{l s='Two Order ID' mod='twopayment'} - {$twopaymentdata.two_order_id} + {$twopaymentdata.two_order_id|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_order_reference}
{l s='Order Reference' mod='twopayment'} - {$twopaymentdata.two_order_reference} + {$twopaymentdata.two_order_reference|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_day_on_invoice}
{l s='Payment Terms' mod='twopayment'} - {$twopaymentdata.two_day_on_invoice} {l s='days' mod='twopayment'} + + {if $twopaymentdata.two_payment_term_type == 'EOM'} + {l s='End of Month' mod='twopayment'} + {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'} {l s='days' mod='twopayment'} + EOM + {else} + {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'} {l s='days' mod='twopayment'} + {/if} + +
+ {else} +
+ {l s='Payment Terms' mod='twopayment'} + {l s='Not recorded for this order' mod='twopayment'} +
+ {/if} + {if $twopaymentdata.two_order_state} +
+ {l s='Two State' mod='twopayment'} + {$twopaymentdata.two_order_state|escape:'html':'UTF-8'} +
+ {/if} + {if $twopaymentdata.two_order_status} +
+ {l s='Two Status' mod='twopayment'} + {$twopaymentdata.two_order_status|escape:'html':'UTF-8'}
{/if} @@ -57,7 +81,7 @@ {elseif $twopaymentdata.two_invoice_upload_status == 'NOT_APPLICABLE'} {l s='N/A' mod='twopayment'} {else} - {$twopaymentdata.two_invoice_upload_status} + {$twopaymentdata.two_invoice_upload_status|escape:'html':'UTF-8'} {/if} @@ -65,56 +89,60 @@ {if $twopaymentdata.two_invoice_uploaded_at}
{l s='Uploaded At' mod='twopayment'} - {$twopaymentdata.two_invoice_uploaded_at} + {$twopaymentdata.two_invoice_uploaded_at|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_invoice_upload_reference}
{l s='Upload Reference' mod='twopayment'} - {$twopaymentdata.two_invoice_upload_reference} + {$twopaymentdata.two_invoice_upload_reference|escape:'html':'UTF-8'}
{/if} {if $twopaymentdata.two_invoice_upload_error}
{l s='Error Message' mod='twopayment'} - {$twopaymentdata.two_invoice_upload_error} + {$twopaymentdata.two_invoice_upload_error|escape:'html':'UTF-8'}
{/if} {/if} - {* Actions Section - now styled like info cards *} + {* Actions Section *}

{l s='Actions' mod='twopayment'}

{if $twopaymentdata.two_order_id && $two_portal_url}
{l s='View in Two Portal' mod='twopayment'} - {l s='Open' mod='twopayment'} + {l s='Open' mod='twopayment'}
{/if} - {if $two_pdf_url} + {if $two_invoice_actions_available && $two_pdf_url}
{l s='Invoice PDF' mod='twopayment'} - {l s='Download' mod='twopayment'} + {l s='Download' mod='twopayment'}
{/if} - {if $twopaymentdata.two_invoice_url} + {if $two_invoice_actions_available && $twopaymentdata.two_invoice_url}
{l s='Invoice URL' mod='twopayment'} - {l s='Open link' mod='twopayment'} + {l s='Open link' mod='twopayment'} +
+ {/if} + {if !$two_invoice_actions_available} +
+ {l s='Invoice Links' mod='twopayment'} + {l s='Available after Two order fulfillment' mod='twopayment'}
{/if} {if $two_portal_url}
{l s='Two Portal' mod='twopayment'} - {l s='Open' mod='twopayment'} + {l s='Open' mod='twopayment'}
{/if}
- - diff --git a/views/templates/hook/displayOrderDetail.tpl b/views/templates/hook/displayOrderDetail.tpl index 6bb8d23..aaa11bb 100644 --- a/views/templates/hook/displayOrderDetail.tpl +++ b/views/templates/hook/displayOrderDetail.tpl @@ -10,27 +10,27 @@ {if $twopaymentdata.two_order_id} - + {/if} {if $twopaymentdata.two_order_reference} - + {/if} {if $twopaymentdata.two_day_on_invoice} - + {/if} {if $twopaymentdata.two_invoice_url} - + {/if} {if $twopaymentdata.two_order_id && $two_portal_url} - + {/if} {if $two_pdf_url} - + {/if} {if $two_portal_url} - + {/if}
{l s='Two Order ID' mod='twopayment'} {$twopaymentdata.two_order_id}
{l s='Two Order ID' mod='twopayment'} {$twopaymentdata.two_order_id|escape:'html':'UTF-8'}
{l s='Two Order Reference' mod='twopayment'} {$twopaymentdata.two_order_reference}
{l s='Two Order Reference' mod='twopayment'} {$twopaymentdata.two_order_reference|escape:'html':'UTF-8'}
{l s='Two Day On Invoice' mod='twopayment'} {$twopaymentdata.two_day_on_invoice}
{l s='Two Day On Invoice' mod='twopayment'} {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'}
{l s='Two Invoice Url' mod='twopayment'} {$twopaymentdata.two_invoice_url}
{l s='Two Invoice Url' mod='twopayment'} {$twopaymentdata.two_invoice_url|escape:'html':'UTF-8'}
{l s='View Order on Portal' mod='twopayment'} {l s='View order details' mod='twopayment'}
{l s='View Order on Portal' mod='twopayment'} {l s='View order details' mod='twopayment'}
{l s='Download PDF Invoice' mod='twopayment'} {l s='Download invoice PDF' mod='twopayment'}
{l s='Download PDF Invoice' mod='twopayment'} {l s='Download invoice PDF' mod='twopayment'}
{l s='Two Portal' mod='twopayment'} {l s='Manage your Two account' mod='twopayment'}
{l s='Two Portal' mod='twopayment'} {l s='Manage your Two account' mod='twopayment'}
- \ No newline at end of file + diff --git a/views/templates/hook/displayPaymentReturn.tpl b/views/templates/hook/displayPaymentReturn.tpl index 3818657..aaa11bb 100644 --- a/views/templates/hook/displayPaymentReturn.tpl +++ b/views/templates/hook/displayPaymentReturn.tpl @@ -10,28 +10,27 @@ {if $twopaymentdata.two_order_id} - + {/if} {if $twopaymentdata.two_order_reference} - + {/if} {if $twopaymentdata.two_day_on_invoice} - + {/if} {if $twopaymentdata.two_invoice_url} - + {/if} {if $twopaymentdata.two_order_id && $two_portal_url} - + {/if} {if $two_pdf_url} - + {/if} {if $two_portal_url} - + {/if}
{l s='Two Order ID' mod='twopayment'} {$twopaymentdata.two_order_id}
{l s='Two Order ID' mod='twopayment'} {$twopaymentdata.two_order_id|escape:'html':'UTF-8'}
{l s='Two Order Reference' mod='twopayment'} {$twopaymentdata.two_order_reference}
{l s='Two Order Reference' mod='twopayment'} {$twopaymentdata.two_order_reference|escape:'html':'UTF-8'}
{l s='Two Day On Invoice' mod='twopayment'} {$twopaymentdata.two_day_on_invoice}
{l s='Two Day On Invoice' mod='twopayment'} {$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'}
{l s='Two Invoice Url' mod='twopayment'} {$twopaymentdata.two_invoice_url}
{l s='Two Invoice Url' mod='twopayment'} {$twopaymentdata.two_invoice_url|escape:'html':'UTF-8'}
{l s='View Order on Portal' mod='twopayment'} {l s='View order details' mod='twopayment'}
{l s='View Order on Portal' mod='twopayment'} {l s='View order details' mod='twopayment'}
{l s='Download PDF Invoice' mod='twopayment'} {l s='Download invoice PDF' mod='twopayment'}
{l s='Download PDF Invoice' mod='twopayment'} {l s='Download invoice PDF' mod='twopayment'}
{l s='Two Portal' mod='twopayment'} {l s='Manage your Two account' mod='twopayment'}
{l s='Two Portal' mod='twopayment'} {l s='Manage your Two account' mod='twopayment'}
- diff --git a/views/templates/hook/displayPaymentReturnBuyer.tpl b/views/templates/hook/displayPaymentReturnBuyer.tpl index 1b9f50e..e27f940 100644 --- a/views/templates/hook/displayPaymentReturnBuyer.tpl +++ b/views/templates/hook/displayPaymentReturnBuyer.tpl @@ -11,18 +11,22 @@ {if $twopaymentdata.two_day_on_invoice} {l s='Invoice Terms' mod='twopayment'} - {l s='%d days' sprintf=[$twopaymentdata.two_day_on_invoice] mod='twopayment'} + + {if isset($twopaymentdata.two_payment_term_type) && $twopaymentdata.two_payment_term_type == 'EOM'} + {l s='End of Month + %d days' sprintf=[$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'] mod='twopayment'} + {else} + {l s='Standard + %d days' sprintf=[$twopaymentdata.two_day_on_invoice|escape:'html':'UTF-8'] mod='twopayment'} + {/if} + {/if} {if $two_buyer_portal_url} {l s='Two Buyer Portal' mod='twopayment'} - {l s='Access your Two buyer portal to view this order once fulfilled' mod='twopayment'} + {l s='Access your Two buyer portal to view this order once fulfilled' mod='twopayment'} {/if} - - diff --git a/views/templates/hook/paymentinfo.tpl b/views/templates/hook/paymentinfo.tpl index af596af..ea8a777 100644 --- a/views/templates/hook/paymentinfo.tpl +++ b/views/templates/hook/paymentinfo.tpl @@ -15,7 +15,7 @@ {* Header Section *}
- +

{l s='Business payments made simple' mod='twopayment'} @@ -52,7 +52,7 @@ {* Payment Info Section - Dynamically populated by JavaScript *}

@@ -60,16 +60,16 @@