From abdca90cb77b49dc9fbc8f16a24c964aa4e4fcaa Mon Sep 17 00:00:00 2001 From: Douglas Lindsay Date: Tue, 12 May 2026 23:22:59 +0100 Subject: [PATCH 01/11] TWO-24485: rewrite plugin as brand-aware Two_Gateway module Imports the cleaned Two_Gateway runtime from the consolidated private branch. The module is brand-aware via Two\Gateway\Api\BrandRegistryInterface, with a default DI binding to Two\Gateway\Brand\TwoBrand. Downstream brand-overlay packages may rebind this interface via their own DI preference. Bumps to 2.0.0-rc.1 to signal the architectural change. See internal ticket for the full design rationale. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/release-notes.py | 130 ----------- .github/workflows/auto-pr.yml | 68 ++---- .github/workflows/release.yml | 117 +++++----- .gitignore | 5 + AGENTS.md | 122 +--------- Api/BrandRegistryInterface.php | 71 ++++++ Api/Config/RepositoryInterface.php | 17 +- Api/CurrencyRatesProviderInterface.php | 40 ++++ Api/Webapi/SoleTraderInterface.php | 2 +- Api/Webapi/SurchargesInterface.php | 41 ++++ Api/Webapi/TermSelectionInterface.php | 24 +- .../Creditmemo/SurchargeOverride.php | 151 ++++++++++++ Block/Adminhtml/Order/View.php | 14 ++ .../Adminhtml/System/Config/Field/Header.php | 14 ++ .../Config/Field/PaymentTermsCheckboxes.php | 16 +- .../System/Config/Field/SurchargeGrid.php | 167 +++++++++++++- .../Adminhtml/System/Config/Field/Version.php | 2 +- Block/Sales/Total/Surcharge.php | 67 ++++++ Brand/TwoBrand.php | 57 +++++ Controller/Adminhtml/Config/Fees.php | 70 ++---- Controller/Payment/Cancel.php | 8 +- Controller/Payment/Confirm.php | 16 +- Controller/Payment/Verificationfailed.php | 8 +- Model/Config/Backend/SurchargeGrid.php | 55 ++++- Model/Config/Repository.php | 75 ++++-- Model/Config/Source/AvailablePaymentTerms.php | 11 +- .../Source/PaymentTermsDurationDays.php | 12 +- Model/Config/Source/PaymentTermsType.php | 7 +- Model/CurrencyRatesProvider.php | 93 ++++++++ Model/Pdf/Total/Surcharge.php | 44 ++++ Model/Total/Creditmemo/Surcharge.php | 117 ++++++++++ Model/Total/Invoice/Surcharge.php | 74 ++++++ Model/Total/Surcharge.php | 95 ++++++-- Model/Two.php | 36 +-- Model/Ui/ConfigProvider.php | 95 ++------ Model/Webapi/Surcharges.php | 141 ++++++++++++ Model/Webapi/TermSelection.php | 36 ++- Observer/CreditmemoSurchargeRunningTotal.php | 140 ++++++++++++ Observer/InvoiceSurchargeRunningTotal.php | 174 ++++++++++++++ Observer/QuoteToOrderSurcharge.php | 73 ++++++ Observer/SalesOrderAddressUpdate.php | 8 +- Observer/SalesOrderCancelAfter.php | 107 +++++++++ Observer/SalesOrderSaveAfter.php | 73 +++--- Observer/SalesOrderShipmentAfter.php | 134 +++++------ Plugin/Api/SurchargeExtensionAttributes.php | 142 ++++++++++++ .../Sales/CreditmemoSurchargeOverride.php | 130 +++++++++++ README.md | 0 Service/Api/Adapter.php | 8 +- Service/Order.php | 6 +- Service/Order/ComposeCapture.php | 4 +- Service/Order/ComposeOrder.php | 29 ++- Service/Order/ComposeRefund.php | 108 ++++++++- Service/Order/ComposeShipment.php | 2 +- Service/Order/SurchargeCalculator.php | 214 ++++++++---------- Service/Payment/OrderService.php | 38 +++- Setup/Patch/Data/OrderStatuses.php | 10 +- Setup/Patch/Data/PendingPaymentStatus.php | 8 +- .../Config/Backend/SurchargeGridTest.php | 4 +- Test/Unit/Service/Api/AdapterTest.php | 2 +- composer.json | 4 +- etc/adminhtml/di.xml | 4 + etc/adminhtml/routes.xml | 8 +- etc/adminhtml/system.xml | 22 +- etc/config.xml | 13 +- etc/db_schema.xml | 50 +++- etc/db_schema_whitelist.json | 64 ++++++ etc/di.xml | 45 ++++ etc/events.xml | 12 + etc/extension_attributes.xml | 28 +++ etc/fieldset.xml | 36 +++ etc/sales.xml | 10 + etc/webapi.xml | 6 + frpc.toml | 13 -- i18n/nl_NL.csv | 2 +- i18n/sv_SE.csv | 2 +- start-proxy.sh | 108 --------- .../layout/sales_order_creditmemo_new.xml | 19 ++ .../sales_order_creditmemo_updateqty.xml | 19 ++ .../layout/sales_order_creditmemo_view.xml | 15 ++ .../layout/sales_order_invoice_new.xml | 15 ++ .../layout/sales_order_invoice_view.xml | 15 ++ view/adminhtml/layout/sales_order_view.xml | 3 + view/adminhtml/requirejs-config.js | 5 +- .../adminhtml/templates/order/view/view.phtml | 4 +- .../creditmemo/two_surcharge_input.phtml | 24 ++ .../system/config/field/header.phtml | 15 +- .../system/config/field/surcharge-grid.phtml | 49 +++- .../system/config/field/version.phtml | 4 +- .../ui_component/sales_order_grid.xml | 19 ++ view/adminhtml/web/css/source/_module.less | 1 + .../web/css/source/_surcharge-grid.less | 28 ++- view/adminhtml/web/js/payment-terms-config.js | 10 +- view/adminhtml/web/js/surcharge-grid.js | 103 ++++++--- .../sales_email_order_creditmemo_items.xml | 15 ++ .../sales_email_order_invoice_items.xml | 15 ++ .../layout/sales_email_order_items.xml | 15 ++ .../layout/sales_order_creditmemo_view.xml | 15 ++ .../layout/sales_order_invoice_view.xml | 15 ++ view/frontend/layout/sales_order_view.xml | 15 ++ view/frontend/requirejs-config.js | 3 + view/frontend/web/css/style.css | 196 ++++++++-------- .../js/model/new-customer-address-mixin.js | 4 + view/frontend/web/js/model/surcharge.js | 183 ++++++++++++--- .../web/js/view/address-autocomplete.js | 2 +- .../web/js/view/checkout/summary/surcharge.js | 7 +- .../js/view/messages-sticky-errors-mixin.js | 29 +++ .../payment/method-renderer/two_payment.js | 55 ++++- .../web/template/payment/two_payment.html | 71 +++--- 108 files changed, 3723 insertions(+), 1224 deletions(-) delete mode 100755 .github/scripts/release-notes.py create mode 100644 Api/BrandRegistryInterface.php create mode 100644 Api/CurrencyRatesProviderInterface.php create mode 100644 Api/Webapi/SurchargesInterface.php create mode 100644 Block/Adminhtml/Creditmemo/SurchargeOverride.php create mode 100644 Block/Sales/Total/Surcharge.php create mode 100644 Brand/TwoBrand.php create mode 100644 Model/CurrencyRatesProvider.php create mode 100644 Model/Pdf/Total/Surcharge.php create mode 100644 Model/Total/Creditmemo/Surcharge.php create mode 100644 Model/Total/Invoice/Surcharge.php create mode 100644 Model/Webapi/Surcharges.php create mode 100644 Observer/CreditmemoSurchargeRunningTotal.php create mode 100644 Observer/InvoiceSurchargeRunningTotal.php create mode 100644 Observer/QuoteToOrderSurcharge.php create mode 100644 Observer/SalesOrderCancelAfter.php create mode 100644 Plugin/Api/SurchargeExtensionAttributes.php create mode 100644 Plugin/Model/Sales/CreditmemoSurchargeOverride.php mode change 100755 => 100644 README.md mode change 100644 => 100755 composer.json mode change 100755 => 100644 etc/adminhtml/di.xml create mode 100644 etc/db_schema_whitelist.json mode change 100755 => 100644 etc/events.xml create mode 100644 etc/fieldset.xml delete mode 100644 frpc.toml delete mode 100755 start-proxy.sh create mode 100644 view/adminhtml/layout/sales_order_creditmemo_new.xml create mode 100644 view/adminhtml/layout/sales_order_creditmemo_updateqty.xml create mode 100644 view/adminhtml/layout/sales_order_creditmemo_view.xml create mode 100644 view/adminhtml/layout/sales_order_invoice_new.xml create mode 100644 view/adminhtml/layout/sales_order_invoice_view.xml create mode 100644 view/adminhtml/templates/sales/order/creditmemo/two_surcharge_input.phtml create mode 100644 view/adminhtml/ui_component/sales_order_grid.xml create mode 100644 view/frontend/layout/sales_email_order_creditmemo_items.xml create mode 100644 view/frontend/layout/sales_email_order_invoice_items.xml create mode 100644 view/frontend/layout/sales_email_order_items.xml create mode 100644 view/frontend/layout/sales_order_creditmemo_view.xml create mode 100644 view/frontend/layout/sales_order_invoice_view.xml create mode 100644 view/frontend/layout/sales_order_view.xml mode change 100755 => 100644 view/frontend/requirejs-config.js create mode 100644 view/frontend/web/js/view/messages-sticky-errors-mixin.js mode change 100755 => 100644 view/frontend/web/js/view/payment/method-renderer/two_payment.js mode change 100755 => 100644 view/frontend/web/template/payment/two_payment.html diff --git a/.github/scripts/release-notes.py b/.github/scripts/release-notes.py deleted file mode 100755 index d0b4dcbf..00000000 --- a/.github/scripts/release-notes.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Build bucketed release notes from a git commit range. - -Usage: scripts/release-notes.py -Example: scripts/release-notes.py "abn-main..abn-develop" body.md - -Writes the bucketed markdown to ; prints the semver -bump level (major | minor | patch) to stdout. - -Used by: - -- auto-pr.yml — populates the rolling sync PR's body with a - preview of the next release. -- release.yml — renders the GitHub Release page's notes after - the bump. - -Bucketing rules — match the conventional-commit type, with an -optional Linear ticket prefix (e.g. ABN-123/feat:): - - Breaking — !: anywhere on subject, or 'BREAKING CHANGE:' trailer - Features — feat[(scope)]: - Fixes — fix[(scope)]: - Internals — chore | refactor | ci | docs | test | build | perf | style - Other — anything that doesn't match - -Bump level = highest-severity bucket that has at least one entry: - - any Breaking → major - any Features → minor - else → patch -""" - -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path - -# Optional `/` prefix where TICKET is uppercase letters + digits -# (ABN-123, INF-1159, etc.). Optional `(scope)`. Required type, then `:`. -TICKET = r"(?:[A-Z]+-[0-9]+/)?" -SCOPE = r"(?:\([^)]+\))?" - -BREAKING = re.compile(rf"^{TICKET}[a-z]+{SCOPE}!:|^BREAKING CHANGE:") -FEATURE = re.compile(rf"^{TICKET}feat{SCOPE}:") -FIX = re.compile(rf"^{TICKET}fix{SCOPE}:") -INTERNAL = re.compile( - rf"^{TICKET}(?:chore|refactor|ci|docs|test|build|perf|style){SCOPE}:" -) - -# Order matters: Breaking is checked before Feature/Fix because -# `feat!:` should land in Breaking, not Features. -BUCKETS: list[tuple[str, str, re.Pattern[str]]] = [ - ("breaking", "## ⚠️ Breaking changes", BREAKING), - ("features", "## 🚀 Features", FEATURE), - ("fixes", "## 🐛 Fixes", FIX), - ("internals", "## 🧰 Internals", INTERNAL), - # The "other" bucket is the catch-all and has no regex. -] - - -def git_log(commit_range: str) -> list[tuple[str, str]]: - """Return list of (short_sha, subject) for non-merge commits in the range.""" - out = subprocess.check_output( - ["git", "log", commit_range, "--no-merges", "--format=%h%x09%s"], - text=True, - ) - rows: list[tuple[str, str]] = [] - for line in out.splitlines(): - if "\t" in line: - sha, subject = line.split("\t", 1) - rows.append((sha, subject)) - return rows - - -def bucket_commits( - commits: list[tuple[str, str]], -) -> dict[str, list[str]]: - """Group commits into the named buckets defined by BUCKETS + 'other'.""" - grouped: dict[str, list[str]] = {key: [] for key, _, _ in BUCKETS} - grouped["other"] = [] - for sha, subject in commits: - line = f"- {subject} ({sha})" - for key, _, pattern in BUCKETS: - if pattern.search(subject): - grouped[key].append(line) - break - else: - grouped["other"].append(line) - return grouped - - -def pick_level(grouped: dict[str, list[str]]) -> str: - if grouped["breaking"]: - return "major" - if grouped["features"]: - return "minor" - return "patch" - - -def render(grouped: dict[str, list[str]]) -> str: - sections: list[str] = [] - for key, heading, _ in BUCKETS: - if grouped[key]: - sections.append(heading) - sections.extend(grouped[key]) - sections.append("") - if grouped["other"]: - sections.append("## Other") - sections.extend(grouped["other"]) - sections.append("") - return "\n".join(sections) - - -def main(argv: list[str]) -> int: - if len(argv) != 3: - print(f"usage: {argv[0]} ", file=sys.stderr) - return 2 - commit_range, out_path = argv[1], argv[2] - - commits = git_log(commit_range) - grouped = bucket_commits(commits) - Path(out_path).write_text(render(grouped)) - print(pick_level(grouped)) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml index 6bf90351..f841bdf7 100644 --- a/.github/workflows/auto-pr.yml +++ b/.github/workflows/auto-pr.yml @@ -15,11 +15,6 @@ jobs: runs-on: ${{ vars.RUNNER_STANDARD }} steps: - uses: actions/checkout@v4 - with: - # Need full history so `.github/scripts/release-notes.py` can walk - # every commit not yet on main when building the preview - # release notes. - fetch-depth: 0 # Use the org GitHub App token — PRs opened with the default # GITHUB_TOKEN do not trigger downstream workflows, so the @@ -31,55 +26,34 @@ jobs: client-id: ${{ vars.TWO_INC_APP_CLIENT_ID }} private-key: ${{ secrets.TWO_INC_APP_PRIVATE_KEY }} - - name: Build preview of next release notes - id: preview + - name: Ensure open PR develop → main env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} run: | set -euo pipefail + existing=$(gh pr list --base main --head develop --state open --json number --jq '.[0].number // ""') + if [ -n "$existing" ]; then + echo "PR #$existing already open — nothing to do." + exit 0 + fi + # No-op when develop has nothing ahead of main. Happens + # right after a merge-back fast-forwards develop to match + # main — there's no diff for `gh pr create`, which would + # otherwise fail with "No commits between main and develop" + # and leave a red check on the auto-PR workflow. ahead=$(gh api "repos/${REPO}/compare/main...develop" --jq '.ahead_by') - echo "ahead=$ahead" >> "$GITHUB_OUTPUT" if [ "$ahead" = "0" ]; then echo "develop has no commits ahead of main — nothing to sync." exit 0 fi - - # release-notes.py is the same script release.yml uses to - # render the GitHub Release page, so this preview matches - # what will actually ship when the sync PR merges. - # Use origin/main since actions/checkout only creates the - # workflow's own ref as a local branch — `main` alone - # wouldn't resolve here. - level=$(.github/scripts/release-notes.py "origin/main..HEAD" notes.md) - - { - echo "Rolling sync PR opened by \`.github/workflows/auto-pr.yml\`. Merging this PR fires \`release.yml\` on \`main\` (auto bump + tag + GitHub Release) and \`merge-back.yml\` (fast-forwards \`develop\` to match)." - echo - echo "Predicted bump: **${level}** — based on the commits below." - echo - cat notes.md - echo - echo "_Close this PR manually if you need to skip a sync window._" - } > body.md - - echo "--- body.md preview ---" - cat body.md - - - name: Ensure open PR with current preview body - if: steps.preview.outputs.ahead != '0' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - existing=$(gh pr list --base main --head develop --state open --json number --jq '.[0].number // ""') - if [ -n "$existing" ]; then - echo "Updating PR #$existing body with refreshed preview..." - gh pr edit "$existing" --body-file body.md - else - gh pr create \ - --base main \ - --head develop \ - --title "chore: sync develop → main" \ - --body-file body.md - fi + gh pr create \ + --base main \ + --head develop \ + --title "chore: sync develop → main" \ + --body "$(cat <<'EOF' + Automated rolling sync PR opened by `.github/workflows/auto-pr-develop-to-main.yml`. + + Merges everything currently on `develop` into `main`. Auto-updates as new commits land on `develop`. Close manually if you need to skip a sync window. + EOF + )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1640c602..c6e1dada 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,7 @@ name: Release -# Triggered after CI completes on main, not on the raw push. -# Release only fires when CI's conclusion is 'success' (gated below), -# so a broken commit landing on main can't produce a tagged -# Release page. on: - workflow_run: - workflows: [CI] - types: [completed] + push: branches: [main] permissions: @@ -20,18 +14,9 @@ concurrency: jobs: release: runs-on: ${{ vars.RUNNER_STANDARD }} - # Two gates: - # 1. CI must have succeeded on the same SHA. workflow_run fires - # on every CI completion (success/failure/cancelled); this - # `if:` filters to the green case. - # 2. Skip ourselves: bumpver's commit lands on main and triggers - # another CI run; once that CI finishes, the release.yml - # workflow_run fires again. Skip when the head commit message - # is already a `chore: Bump version` commit. - # (HEAD-already-tagged check below is the second guard.) - if: | - github.event.workflow_run.conclusion == 'success' && - !startsWith(github.event.workflow_run.head_commit.message, 'chore: Bump version') + # Skip ourselves: bumpver's commit lands on main and re-fires + # this workflow. Without this guard we'd loop forever. + if: "!startsWith(github.event.head_commit.message, 'chore: Bump version')" steps: - name: Mint GitHub App token id: app-token @@ -42,43 +27,25 @@ jobs: - uses: actions/checkout@v5 with: - # Check out the branch (not the SHA) so HEAD lands on - # `main` rather than detached — bumpver's commit then - # advances the branch, and the later `git push origin - # main` actually pushes the bump. ref: main fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - name: Should we release? - # Two reasons to skip: - # 1. Branch drift — workflow_run fires after CI completes, - # but another commit could land on main in the small - # window before release runs. If branch HEAD no longer - # matches the SHA CI signed off on, the next CI cycle - # will retry against the new tip. - # 2. Already tagged — re-runs on the same commit (or on - # the bumpver commit itself) shouldn't produce a - # duplicate release. Match bare numeric tags - # (1.14.1 etc.) — the repo's established convention. + - name: Skip if HEAD already tagged id: gate - env: - PASSED_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail - actual=$(git rev-parse HEAD) - if [ "$actual" != "$PASSED_SHA" ]; then - echo "::warning::main moved from ${PASSED_SHA} to ${actual} between CI and release. Skipping." - echo "skip=1" >> "$GITHUB_OUTPUT" - exit 0 - fi + # Match bare numeric tags (1.14.1 etc.) — the repo's + # established convention. Reject any non-numeric tag prefix + # so a stale fork tag pointing at HEAD doesn't suppress a + # legitimate release. existing=$(git tag --points-at HEAD | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) if [ -n "$existing" ]; then echo "HEAD already tagged as $existing — nothing to release." echo "skip=1" >> "$GITHUB_OUTPUT" - exit 0 + else + echo "skip=0" >> "$GITHUB_OUTPUT" fi - echo "skip=0" >> "$GITHUB_OUTPUT" - name: Set up Python if: steps.gate.outputs.skip == '0' @@ -97,11 +64,15 @@ jobs: git config user.email "${{ vars.TWO_INC_APP_ID }}+two-inc-app[bot]@users.noreply.github.com" - name: Decide bump level + build release notes - # Both the bump level and the markdown notes come out of - # .github/scripts/release-notes.py — the same script auto-pr.yml - # uses to render the rolling sync PR's preview, so the - # GitHub Release page exactly matches what was previewed - # before merge. + # Single pass over `..HEAD`: + # - Buckets each non-merge commit into Breaking / Features / + # Fixes / Internals / Other based on its conventional-commit + # type, with optional Linear ticket prefix (e.g. CET-123/feat:). + # - Writes the bucketed list to release-notes.md so the GitHub + # Release page reflects exactly what triggered the bump level + # (any Breaking entries → major; any Features → minor; else + # patch). Reading the notes makes the bump-level decision + # visible without having to look at the workflow log. if: steps.gate.outputs.skip == '0' id: bump run: | @@ -113,10 +84,52 @@ jobs: range="HEAD" fi - level=$(.github/scripts/release-notes.py "$range" release-notes.md) + : > release-notes.md + : > /tmp/breaking + : > /tmp/features + : > /tmp/fixes + : > /tmp/internals + : > /tmp/other + + while IFS=$'\t' read -r sha subject; do + line="- ${subject} (${sha})" + if echo "$subject" | grep -qE '^([A-Z]+-[0-9]+/)?[a-z]+(\([^)]+\))?!:|^BREAKING CHANGE:'; then + echo "$line" >> /tmp/breaking + elif echo "$subject" | grep -qE '^([A-Z]+-[0-9]+/)?feat(\([^)]+\))?:'; then + echo "$line" >> /tmp/features + elif echo "$subject" | grep -qE '^([A-Z]+-[0-9]+/)?fix(\([^)]+\))?:'; then + echo "$line" >> /tmp/fixes + elif echo "$subject" | grep -qE '^([A-Z]+-[0-9]+/)?(chore|refactor|ci|docs|test|build|perf|style)(\([^)]+\))?:'; then + echo "$line" >> /tmp/internals + else + echo "$line" >> /tmp/other + fi + done < <(git log "$range" --no-merges --format='%h%x09%s') + + # Bump level is the highest-severity bucket that's non-empty. + if [ -s /tmp/breaking ]; then + level=major + elif [ -s /tmp/features ]; then + level=minor + else + level=patch + fi echo "level=$level" >> "$GITHUB_OUTPUT" - echo "prev=$prev" >> "$GITHUB_OUTPUT" echo "Selected bump level: $level (range: $range)" + + # Render notes — only print sections that have entries. + { + [ -s /tmp/breaking ] && { echo "## ⚠️ Breaking changes"; cat /tmp/breaking; echo; } + [ -s /tmp/features ] && { echo "## 🚀 Features"; cat /tmp/features; echo; } + [ -s /tmp/fixes ] && { echo "## 🐛 Fixes"; cat /tmp/fixes; echo; } + [ -s /tmp/internals ] && { echo "## 🧰 Internals"; cat /tmp/internals; echo; } + [ -s /tmp/other ] && { echo "## Other"; cat /tmp/other; echo; } + } > release-notes.md + # Stash the previous tag so the post-bump step can append a + # "Full diff" link with the proper .. range — + # the new tag isn't known yet at this point. + echo "prev=$prev" >> "$GITHUB_OUTPUT" + echo "--- release-notes.md preview ---" cat release-notes.md @@ -143,7 +156,7 @@ jobs: - name: Append full-diff link to release notes # Now that the new version is known we can render a proper # .. link instead of leaving the right-hand side - # blank (the prior shape rendered as "abn-1.13.2.."). + # blank (the prior shape rendered as "-1.13.2.."). if: steps.gate.outputs.skip == '0' env: PREV: ${{ steps.bump.outputs.prev }} diff --git a/.gitignore b/.gitignore index 69b0aeec..9f64dfbd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ composer.lock # Python *.venv .phpunit.result.cache +.fork-compare/ +.serena/ +agent-notes/ +upstream-backport-brief.md +plans/ diff --git a/AGENTS.md b/AGENTS.md index 465beba5..aac8b2d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,116 +1,12 @@ -# Magento Plugin (Two Gateway) +# Magento Plugin (Two_Gateway) -## Project Overview +Two's Magento 2 BNPL payment plugin. Brand-aware single-module +extension; brand-specific identity values resolve through +`Two\Gateway\Api\BrandRegistryInterface`. The default DI +binding in `etc/di.xml` resolves to `Two\Gateway\Brand\TwoBrand`. -Two's Magento 2 payment plugin, providing BNPL (Buy Now Pay Later) checkout integration for Magento stores. +Standard Magento dev workflow: composer install, bin/magento +setup:di:compile, setup:upgrade, cache:flush. PHPUnit under Test/. -- **Language**: PHP 7.4+ -- **Framework**: Magento 2 module -- **Purpose**: Payment gateway integration for Two BNPL service - -## Directory Structure - -``` -etc/ # Module configuration (module.xml, di.xml, system.xml) -Model/ # Business logic and data models -Controller/ # Controllers for routes -Block/ # View layer blocks -view/ # Frontend/adminhtml templates and layouts -Observer/ # Event observers -Plugin/ # Plugins (interceptors) -Setup/ # Installation/upgrade scripts -i18n/ # Translations -``` - -## Git Workflow - -- Use `SKIP=commit-msg` when committing on `main` branch (no Linear ticket needed) -- Do NOT skip commit-msg hook on feature branches -- Never use `--no-verify` flag - -## Version Management - -Version bumps are done using `bumpver`: - -```bash -SKIP=commit-msg bumpver update --patch # or --minor, --major -git push origin main --tags -``` - -## Translations - -- Translation files: `nb_NO.csv`, `nl_NL.csv`, `sv_SE.csv` -- No `en_US.csv` needed - Magento falls back to source strings for English - -## Admin Panel Configuration - -- Most config fields should have `canRestore="1"` to allow website/store scope inheritance -- Sensitive fields (mode, api_key, debug) should NOT have `canRestore` - they must be explicitly set -- Button-type fields (version, api_key_check, etc.) don't need `canRestore` -- Use `translate="label comment"` when field has both label and comment to translate - -### Config Paths - -All payment config is stored under `payment/two_payment/`: - -- `payment/two_payment/active` - Enable/disable -- `payment/two_payment/mode` - Environment (sandbox/staging/production) -- `payment/two_payment/api_key` - API key (encrypted) -- `payment/two_payment/debug` - Debug mode - -### Setting Config via CLI - -```bash -bin/magento config:set payment/two_payment/mode sandbox -bin/magento config:set payment/two_payment/active 1 -bin/magento cache:flush config -``` - -## Development Tips - -### Running Commands - -Most Magento CLI commands should be run as the web server user to avoid permission issues: - -```bash -su www-data -s /bin/bash -c "bin/magento " -``` - -### Cache Clearing - -After making changes, clear caches in this order: - -```bash -# 1. Clear generated code (if PHP classes changed) -rm -rf generated/code/Two - -# 2. Recompile DI (if new classes/interceptors) -bin/magento setup:di:compile - -# 3. Deploy admin static content (if admin templates/CSS changed) -rm -rf pub/static/adminhtml/* var/view_preprocessed/pub/static/adminhtml/* -bin/magento setup:static-content:deploy -f --area=adminhtml - -# 4. Flush all caches -bin/magento cache:flush - -# 5. Clear PHP opcache (if opcache.validate_timestamps=0) -# Create pub/opcache-clear.php or restart PHP-FPM -``` - -## Session Artifacts - -This is a **public repository**. Do not commit session-specific content such as: -- Session summaries or transcripts -- Implementation plans or review notes -- Any file under `docs/` that contains conversation context - -Use agent memory (e.g. `~/.claude/projects/` or equivalent) for session persistence instead. Plans can be saved locally and stashed but must not be committed. - -### Common Issues - -1. **Template not found error**: Run `setup:di:compile` and clear opcache -2. **Stale worktree paths in errors**: Delete `generated/code/Two` and recompile DI -3. **Admin CSS/logo missing**: Redeploy admin static content -4. **Permission denied on var/cache**: Fix ownership with `chown -R www-data:www-data var/ generated/` -5. **Config changes not appearing**: Flush config cache and clear opcache +This is a **public repository**. Do not commit session-specific +content such as plans, transcripts, or implementation notes. diff --git a/Api/BrandRegistryInterface.php b/Api/BrandRegistryInterface.php new file mode 100644 index 00000000..8c8be334 --- /dev/null +++ b/Api/BrandRegistryInterface.php @@ -0,0 +1,71 @@ +`). Empty string ('') means do not decorate + * — the URL host already conveys the brand. Implementations may + * return a brand tag so that shared sandbox/staging hosts can + * route correctly. + */ + public function getBrandTag(): string; +} diff --git a/Api/Config/RepositoryInterface.php b/Api/Config/RepositoryInterface.php index de1fb379..f3ac1dd0 100755 --- a/Api/Config/RepositoryInterface.php +++ b/Api/Config/RepositoryInterface.php @@ -12,14 +12,13 @@ */ interface RepositoryInterface { - /** Provider specific config */ + /** Magento payment-method code (canonical, brand-independent). */ public const CODE = 'two_payment'; - public const PROVIDER = 'Two'; - public const PROVIDER_FULL_NAME = 'Two'; - public const PRODUCT_NAME = 'Two'; - public const PAYMENT_TERMS_LINK = 'https://www.two.inc/terms-privacy'; - public const PAYMENT_TERMS_EMAIL = 'invoice@two.inc'; - public const URL_TEMPLATE = 'https://%s.two.inc'; + + // Brand-bound values (PROVIDER, PROVIDER_FULL_NAME, PRODUCT_NAME, + // URL_TEMPLATE, AVAILABLE_PAYMENT_TERMS, SURCHARGE_FIXED_MAX[_CURRENCY]) + // moved to Two\Gateway\Api\BrandRegistryInterface — inject the registry + // and call its methods rather than re-introducing constants here. /** Payment Group */ public const XML_PATH_ENABLED = 'payment/two_payment/active'; @@ -51,9 +50,7 @@ interface RepositoryInterface public const XML_PATH_VERSION = 'payment/two_payment/version'; public const XML_PATH_DEBUG = 'payment/two_payment/debug'; - /** Configurable limits — override in fork */ - public const AVAILABLE_PAYMENT_TERMS = [14, 30, 60, 90]; - public const SURCHARGE_FIXED_MAX = 100; + /** Brand-independent surcharge ceiling (percent). */ public const SURCHARGE_PERCENTAGE_MAX = 100; /** Weight unit */ diff --git a/Api/CurrencyRatesProviderInterface.php b/Api/CurrencyRatesProviderInterface.php new file mode 100644 index 00000000..01aab05a --- /dev/null +++ b/Api/CurrencyRatesProviderInterface.php @@ -0,0 +1,40 @@ +getQuote() + * as the authoritative quote source, and computes the basis from it + * (grand_total minus any surcharge segment already collected this + * pass). The $cartId path parameter is retained for URL routing and + * is not used for authorization; trusting an attacker-controlled + * basis here previously allowed unauthenticated probing of merchant + * surcharge configuration. + * + * The server runs an in-memory collectTotals() before reading so + * the basis matches what the frontend's totals observable would + * compute even if no persisting Magento call has fired since the + * last frontend update — preferred over accepting an unbounded + * caller-supplied basis, even at the cost of an extra collector + * pass per request. + * + * @api + * + * @param string $cartId URL-routing only; ignored server-side. + * @return string JSON-encoded {term_surcharges: [{days: int, net: float}, ...]}. + */ + public function get(string $cartId): string; +} diff --git a/Api/Webapi/TermSelectionInterface.php b/Api/Webapi/TermSelectionInterface.php index 279f7624..433b706a 100644 --- a/Api/Webapi/TermSelectionInterface.php +++ b/Api/Webapi/TermSelectionInterface.php @@ -12,11 +12,29 @@ interface TermSelectionInterface /** * Set the buyer's selected payment term and recalculate quote totals. * + * The route is anonymous (guest checkout requires it). The session + * cookie is the auth boundary — server uses checkoutSession->getQuote() + * as the authoritative quote source. The $cartId path parameter is + * retained for URL routing back-compat and is not used for + * authorization; UserContextInterface does not populate on routes + * declared with ``, so an ownership check + * via QuoteIdMaskFactory would be dead code on this surface (see + * internal ticket for the reasoning that applies to both anonymous surcharge + * endpoints). + * + * $termDays is validated against the merchant's configured terms + * (ConfigRepository::getAllBuyerTerms) before any state mutation — + * an unconfigured term would otherwise persist to the session via + * setTwoSelectedTerm and flow through to ComposeOrder at checkout + * completion, causing the Two API to receive a term the merchant + * never offered. See internal ticket. + * * @api * - * @param string $cartId - * @param int $termDays - * @return mixed + * @param string $cartId URL-routing only; ignored server-side. + * @param int $termDays Must be one of the configured buyer terms. + * @return array Wrapped totals + per-term recalculated surcharges. + * @throws \Magento\Framework\Exception\InputException If $termDays is not configured. */ public function selectTerm(string $cartId, int $termDays): array; } diff --git a/Block/Adminhtml/Creditmemo/SurchargeOverride.php b/Block/Adminhtml/Creditmemo/SurchargeOverride.php new file mode 100644 index 00000000..2c89d8f6 --- /dev/null +++ b/Block/Adminhtml/Creditmemo/SurchargeOverride.php @@ -0,0 +1,151 @@ +registry = $registry; + } + + /** + * Replace the static surcharge row registered by Block\Sales\Total\Surcharge + * with an editable input row that points at this block's template. + * + * Mirrors how Magento\Sales\Block\Adminhtml\Order\Creditmemo\Create\Adjustments + * collapses the standard shipping/adjustment rows into its own editable + * block. Runs from the parent totals block's _beforeToHtml after our own + * Surcharge::initTotals has registered the read-only entry, so the order + * of operations is: static added → we remove it → we add the editable + * placeholder pointing at this template. + */ + public function initTotals(): self + { + if (!$this->shouldDisplay()) { + return $this; + } + $parent = $this->getParentBlock(); + if (!$parent) { + return $this; + } + $parent->removeTotal('two_surcharge'); + // Pass the alias (not the full name-in-layout) so the parent + // totals.phtml's $block->getChildHtml($code) lookup resolves. + $parent->addTotalBefore( + new DataObject([ + 'code' => 'two_surcharge', + 'block_name' => 'two_surcharge_override', + 'strong' => false, + ]), + 'grand_total' + ); + return $this; + } + + public function getCreditmemo() + { + return $this->registry->registry('current_creditmemo'); + } + + public function getOrder() + { + $cm = $this->getCreditmemo(); + return $cm ? $cm->getOrder() : null; + } + + /** + * Total surcharge available to refund — order amount minus prior refunds. + */ + public function getMaxRefundable(): float + { + $order = $this->getOrder(); + if (!$order) { + return 0.0; + } + return max( + 0.0, + (float)$order->getTwoSurchargeAmount() - (float)$order->getTwoSurchargeRefunded() + ); + } + + /** + * Default value for the input. Prefers whatever collectTotals just + * produced on the creditmemo (which honours any merchant override + * stamped via the Plugin\Model\Sales\CreditmemoSurchargeOverride + * beforeCollectTotals plugin), falling back to the proportional + * default when the field has not been collected yet. + */ + public function getDefaultRefund(): float + { + $cm = $this->getCreditmemo(); + $order = $this->getOrder(); + if (!$cm || !$order) { + return 0.0; + } + $current = (float)$cm->getTwoSurchargeAmount(); + if ($current > 0) { + return min($current, $this->getMaxRefundable()); + } + $orderSubtotal = (float)$order->getSubtotal(); + $cmSubtotal = (float)$cm->getSubtotal(); + if ($orderSubtotal <= 0) { + return 0.0; + } + $proportion = $cmSubtotal / $orderSubtotal; + $value = round((float)$order->getTwoSurchargeAmount() * $proportion, 2); + return min($value, $this->getMaxRefundable()); + } + + public function shouldDisplay(): bool + { + $order = $this->getOrder(); + return $order && (float)$order->getTwoSurchargeAmount() > 0; + } + + /** + * Label for the row — mirrors the order's surcharge description so the + * editable line on the creditmemo create form reads the same as the + * static line shown elsewhere (e.g. "Zakelijk op Rekening - 30 dagen" + * prefixed with "Refund"). + */ + public function getLabel(): string + { + $order = $this->getOrder(); + $description = $order ? (string)$order->getTwoSurchargeDescription() : ''; + if ($description !== '') { + return (string)__('Refund %1', $description); + } + return (string)__('Refund Two Surcharge'); + } + + public function formatPrice($value): string + { + $order = $this->getOrder(); + if (!$order) { + return (string)$value; + } + return $order->formatPriceTxt((float)$value); + } +} diff --git a/Block/Adminhtml/Order/View.php b/Block/Adminhtml/Order/View.php index a2c49bc6..3545f69b 100755 --- a/Block/Adminhtml/Order/View.php +++ b/Block/Adminhtml/Order/View.php @@ -8,6 +8,7 @@ namespace Two\Gateway\Block\Adminhtml\Order; use Magento\Sales\Block\Adminhtml\Order\View as OrderView; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Service\Api\Adapter as Adapter; @@ -21,6 +22,9 @@ class View extends OrderView */ public $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var Adapter */ @@ -39,6 +43,7 @@ class View extends OrderView */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Adapter $apiAdapter, \Magento\Backend\Block\Widget\Context $context, \Magento\Framework\Registry $registry, @@ -47,6 +52,7 @@ public function __construct( array $data = [] ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->apiAdapter = $apiAdapter; parent::__construct($context, $registry, $salesConfig, $reorderHelper, $data); } @@ -95,4 +101,12 @@ public function getMethod(): string { return $this->getOrder()->getPayment()->getMethod(); } + + /** + * Brand-bound product name for use in templates ($block->getProductName()). + */ + public function getProductName(): string + { + return $this->brandRegistry->getProductName(); + } } diff --git a/Block/Adminhtml/System/Config/Field/Header.php b/Block/Adminhtml/System/Config/Field/Header.php index 58df6633..35a52990 100755 --- a/Block/Adminhtml/System/Config/Field/Header.php +++ b/Block/Adminhtml/System/Config/Field/Header.php @@ -11,6 +11,7 @@ use Magento\Framework\Data\Form\Element\AbstractElement; use Magento\Backend\Block\Template\Context; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -28,6 +29,9 @@ class Header extends Field */ public $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var string */ @@ -40,10 +44,12 @@ class Header extends Field */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Context $context, array $data = [] ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; parent::__construct($context, $data); } @@ -76,4 +82,12 @@ public function getDocumentationUrl(): string { return self::DOCUMENTATION_URL; } + + /** + * Brand-bound product name for use in templates ($block->getProductName()). + */ + public function getProductName(): string + { + return $this->brandRegistry->getProductName(); + } } diff --git a/Block/Adminhtml/System/Config/Field/PaymentTermsCheckboxes.php b/Block/Adminhtml/System/Config/Field/PaymentTermsCheckboxes.php index 7fb1b681..9f3fa769 100644 --- a/Block/Adminhtml/System/Config/Field/PaymentTermsCheckboxes.php +++ b/Block/Adminhtml/System/Config/Field/PaymentTermsCheckboxes.php @@ -7,8 +7,10 @@ namespace Two\Gateway\Block\Adminhtml\System\Config\Field; +use Magento\Backend\Block\Template\Context; use Magento\Config\Block\System\Config\Form\Field; use Magento\Framework\Data\Form\Element\AbstractElement; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -22,6 +24,18 @@ class PaymentTermsCheckboxes extends Field /** @var string */ protected $_template = 'Two_Gateway::system/config/field/payment-terms-checkboxes.phtml'; + /** @var BrandRegistryInterface */ + private $brandRegistry; + + public function __construct( + Context $context, + BrandRegistryInterface $brandRegistry, + array $data = [] + ) { + parent::__construct($context, $data); + $this->brandRegistry = $brandRegistry; + } + /** * @inheritDoc */ @@ -36,7 +50,7 @@ protected function _getElementHtml(AbstractElement $element): string */ public function getAvailableTerms(): array { - return ConfigRepository::AVAILABLE_PAYMENT_TERMS; + return $this->brandRegistry->getAvailablePaymentTerms(); } /** diff --git a/Block/Adminhtml/System/Config/Field/SurchargeGrid.php b/Block/Adminhtml/System/Config/Field/SurchargeGrid.php index d5d639dc..172fbfce 100644 --- a/Block/Adminhtml/System/Config/Field/SurchargeGrid.php +++ b/Block/Adminhtml/System/Config/Field/SurchargeGrid.php @@ -12,7 +12,9 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Data\Form\Element\AbstractElement; use Magento\Store\Model\StoreManagerInterface; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; +use Two\Gateway\Api\CurrencyRatesProviderInterface; /** * Renders a grid of surcharge inputs (fixed, percentage, limit) per payment term. @@ -32,6 +34,12 @@ class SurchargeGrid extends Field /** @var StoreManagerInterface */ private $storeManager; + /** @var CurrencyRatesProviderInterface */ + private $ratesProvider; + + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** @var string */ private $scope = 'default'; @@ -42,11 +50,15 @@ public function __construct( Context $context, ScopeConfigInterface $scopeConfig, StoreManagerInterface $storeManager, + CurrencyRatesProviderInterface $ratesProvider, + BrandRegistryInterface $brandRegistry, array $data = [] ) { parent::__construct($context, $data); $this->scopeConfig = $scopeConfig; $this->storeManager = $storeManager; + $this->ratesProvider = $ratesProvider; + $this->brandRegistry = $brandRegistry; } /** @@ -111,9 +123,27 @@ public function getSurchargeType(): string return (string)$this->getConfigValue(ConfigRepository::XML_PATH_SURCHARGE_TYPE); } - public function getMaxFixed(): int + /** + * Maximum fixed-amount surcharge in the merchant's base currency, + * or null when the brand imposes no upper bound. The template and + * JS treat null as "no max" and skip the range validation. + */ + public function getMaxFixed(): ?int { - return ConfigRepository::SURCHARGE_FIXED_MAX; + $limit = $this->brandRegistry->getSurchargeFixedMax(); + if ($limit === null) { + return null; + } + $limitAmount = (int)$limit['amount']; + $limitCurrency = $limit['currency']; + $baseCurrency = $this->getBaseCurrencyCode(); + + if ($baseCurrency === $limitCurrency) { + return $limitAmount; + } + + $converted = $this->convertAmount((float)$limitAmount, $limitCurrency, $baseCurrency); + return $converted > 0 ? (int)ceil($converted) : $limitAmount; } public function getMaxPercentage(): int @@ -138,7 +168,133 @@ public function getBaseCurrencyCode(): string // Fall through to default } } - return (string)$this->scopeConfig->getValue('currency/options/base') ?: 'USD'; + return (string)$this->scopeConfig->getValue('currency/options/base') ?: 'EUR'; + } + + /** + * Get the base currency symbol (e.g. "€", "$") for the current scope. + * Falls back to the currency code if the symbol isn't resolvable. + */ + public function getBaseCurrencySymbol(): string + { + $code = $this->getBaseCurrencyCode(); + try { + $store = ($this->scope === 'stores' && $this->scopeId > 0) + ? $this->storeManager->getStore($this->scopeId) + : $this->storeManager->getStore(); + $symbol = (string)$store->getBaseCurrency()->getCurrencySymbol(); + return $symbol !== '' ? $symbol : $code; + } catch (\Exception $e) { + return $code; + } + } + + /** + * Fixed-fee limit label (e.g. "EUR 25" or "USD 28 (EUR 25)"). + * Empty when the brand imposes no upper bound — there is nothing + * meaningful to display. + */ + public function getFixedLimitLabel(): string + { + $limit = $this->brandRegistry->getSurchargeFixedMax(); + if ($limit === null) { + return ''; + } + $limitAmount = $limit['amount']; + $limitCurrency = $limit['currency']; + $baseCurrency = $this->getBaseCurrencyCode(); + + if ($baseCurrency === $limitCurrency) { + return $limitCurrency . ' ' . $limitAmount; + } + + $converted = $this->convertAmount((float)$limitAmount, $limitCurrency, $baseCurrency); + if ($converted > 0) { + $precision = $this->getCurrencyPrecision($baseCurrency); + $factor = pow(10, $precision); + $convertedMax = ceil($converted * $factor) / $factor; + $formatted = number_format($convertedMax, $precision, '.', ''); + return $baseCurrency . ' ' . $formatted . ' (' . $limitCurrency . ' ' . $limitAmount . ')'; + } + + return $limitCurrency . ' ' . $limitAmount; + } + + /** + * Get the percentage limit label, e.g. "100%". + */ + public function getPercentageLimitLabel(): string + { + return ConfigRepository::SURCHARGE_PERCENTAGE_MAX . '%'; + } + + /** + * Warning shown when the brand's fixed-fee limit currency differs + * from the merchant's base currency and no FX rate is configured. + * Empty when the brand has no limit at all (nothing to enforce). + */ + public function getCurrencyWarning(): string + { + $limit = $this->brandRegistry->getSurchargeFixedMax(); + if ($limit === null) { + return ''; + } + $limitAmount = $limit['amount']; + $limitCurrency = $limit['currency']; + $baseCurrency = $this->getBaseCurrencyCode(); + + if ($baseCurrency === $limitCurrency) { + return ''; + } + + if ($this->hasExchangeRate($limitCurrency, $baseCurrency)) { + return ''; + } + + return (string)__( + 'Warning: The fixed fee limit of %1 %2 cannot be enforced correctly because no exchange rate is ' + . 'configured from %3 to %4. Configure exchange rates in Stores → Currency → Currency Rates.', + $limitCurrency, + $limitAmount, + $limitCurrency, + $baseCurrency + ); + } + + /** + * Convert an amount from one currency to another. Rate lookup is routed + * through the service contract so all cross-rates resolve via the base + * currency's rate table. + */ + private function convertAmount(float $amount, string $from, string $to): float + { + if ($from === $to) { + return $amount; + } + $rate = $this->ratesProvider->getRate($from, $to, (int)$this->scopeId ?: null); + return $rate !== null ? $amount * $rate : 0.0; + } + + /** + * Check if an exchange rate exists between the limit currency and base currency. + */ + private function hasExchangeRate(string $from, string $to): bool + { + return $this->convertAmount(1.0, $from, $to) > 0; + } + + /** + * Get the number of decimal places for a currency (e.g. 2 for USD/EUR, 0 for JPY). + */ + private function getCurrencyPrecision(string $code): int + { + try { + $fmt = new \NumberFormatter('en_US', \NumberFormatter::CURRENCY); + $fmt->setTextAttribute(\NumberFormatter::CURRENCY_CODE, $code); + return (int)$fmt->getAttribute(\NumberFormatter::FRACTION_DIGITS); + } catch (\Exception $e) { + return 2; + } } /** @@ -177,11 +333,8 @@ public function isInherited(int $days, string $field): bool return false; } $path = sprintf('payment/two_payment/surcharge_%d_%s', $days, $field); - // Check if a value exists at this specific scope $value = $this->scopeConfig->getValue($path, $this->scope, $this->scopeId); $defaultValue = $this->scopeConfig->getValue($path); - // If the scope-specific value equals the default, it's likely inherited - // (Magento doesn't expose "is this overridden" directly for system config) return $value === $defaultValue; } @@ -198,7 +351,7 @@ public function isNonDefaultScope(): bool */ public function getAvailablePaymentTerms(): array { - return ConfigRepository::AVAILABLE_PAYMENT_TERMS; + return $this->brandRegistry->getAvailablePaymentTerms(); } /** diff --git a/Block/Adminhtml/System/Config/Field/Version.php b/Block/Adminhtml/System/Config/Field/Version.php index 9e888a0a..532579f8 100755 --- a/Block/Adminhtml/System/Config/Field/Version.php +++ b/Block/Adminhtml/System/Config/Field/Version.php @@ -31,8 +31,8 @@ class Version extends Field /** * Version constructor. * - * @param ConfigRepository $configRepository * @param Context $context + * @param ConfigRepository $configRepository * @param array $data */ public function __construct( diff --git a/Block/Sales/Total/Surcharge.php b/Block/Sales/Total/Surcharge.php new file mode 100644 index 00000000..99480e35 --- /dev/null +++ b/Block/Sales/Total/Surcharge.php @@ -0,0 +1,67 @@ +getParentBlock(); + if (!$parent) { + return $this; + } + + $source = $parent->getSource(); + if (!$source) { + return $this; + } + + $amount = (float)$source->getDataUsingMethod('two_surcharge_amount'); + $taxAmount = (float)$source->getDataUsingMethod('two_surcharge_tax_amount'); + if ($amount <= 0) { + return $this; + } + + // Display gross (net + tax) so the row matches the invoice/order grand + // total math; tax is otherwise hidden in the Tax line. + $value = $amount + $taxAmount; + $baseAmount = (float)$source->getDataUsingMethod('base_two_surcharge_amount'); + $baseTax = (float)$source->getDataUsingMethod('base_two_surcharge_tax_amount'); + $baseValue = $baseAmount + $baseTax; + + $label = $source->getDataUsingMethod('two_surcharge_description'); + if (!$label) { + $label = (string)__('Two Surcharge'); + } + + $parent->addTotalBefore( + new DataObject([ + 'code' => 'two_surcharge', + 'value' => $value, + 'base_value' => $baseValue, + 'label' => $label, + ]), + 'grand_total' + ); + + return $this; + } +} diff --git a/Brand/TwoBrand.php b/Brand/TwoBrand.php new file mode 100644 index 00000000..3f72bac2 --- /dev/null +++ b/Brand/TwoBrand.php @@ -0,0 +1,57 @@ +resultJsonFactory = $resultJsonFactory; $this->apiAdapter = $apiAdapter; $this->storeManager = $storeManager; $this->scopeConfig = $scopeConfig; - $this->currencyFactory = $currencyFactory; + $this->currencyRates = $currencyRates; } /** @@ -93,8 +93,7 @@ public function execute() '/pricing/v1/merchant/rates', [ 'buyer_country_code' => $this->resolveBuyerCountry($storeId), - // TODO: no admin recourse-pricing config exists yet. Matches - // SurchargeCalculator::fetchBuyerFee hardcode. + // TODO: no admin recourse-pricing config exists yet. 'recourse_pricing' => false, // payout_schedule intentionally omitted — server infers from // the merchant's payee accounts. Only set if/when we expose @@ -110,7 +109,7 @@ public function execute() return $result->setData($normalised); } - return $result->setData($this->convertFees($normalised, $targetCurrency)); + return $result->setData($this->convertFees($normalised, $targetCurrency, $storeId)); } /** @@ -184,19 +183,21 @@ private function resolveTargetCurrency(): string // fall through } } - return (string)$this->scopeConfig->getValue('currency/options/base') ?: 'USD'; + return (string)$this->scopeConfig->getValue('currency/options/base') ?: 'EUR'; } /** - * FX-convert each fee's fixed amount from the API's source currency into - * the grid's display currency. Percentage is dimensionless. + * FX-convert each fee's fixed amount from the API's source currency + * into the grid's display currency. Percentage is dimensionless. * - * Graceful degrade: on FX failure (typically no rate configured under - * Stores > Currency Rates) the fees pass through in the source currency. - * JS shows the currency code inline on the cell so merchants see real - * numbers instead of "—". + * FX failure (no rate in either direction under Stores > Currency Rates) + * falls through: fees stay in the source currency and the JS renders them + * with the code inline (e.g. "2.51% + 0.10 GBP"). Merchant fees are + * billed in the merchant's payout currency regardless, so source-currency + * display is semantically honest and unblocks admins without FX + * configured. */ - private function convertFees(array $raw, string $targetCurrency): array + private function convertFees(array $raw, string $targetCurrency, ?int $storeId): array { if (empty($raw['success']) || empty($raw['fees'])) { return $raw; @@ -207,10 +208,9 @@ private function convertFees(array $raw, string $targetCurrency): array return $raw; } - $rate = $this->resolveFxRate($sourceCurrency, $targetCurrency); + $rate = $this->currencyRates->getRate($sourceCurrency, $targetCurrency, $storeId); if ($rate === null) { - // Leave fees in source currency — JS will label the cell. - return $raw; + return $raw; // leave fees in source currency } foreach ($raw['fees'] as $days => $fee) { @@ -219,39 +219,9 @@ private function convertFees(array $raw, string $targetCurrency): array } } $raw['currency'] = $targetCurrency; - return $raw; } - /** - * Look up a direct FX rate; if absent, try the inverse and invert it. - * - * Magento's admin only captures rates in one direction (typically from - * the store's base currency outward), and Currency::convert() does no - * inversion. Checking both directions avoids forcing the merchant to - * duplicate every rate row just to display our fee column. - */ - private function resolveFxRate(string $source, string $target): ?float - { - try { - $direct = (float)$this->currencyFactory->create()->load($source)->getRate($target); - if ($direct > 0) { - return $direct; - } - } catch (\Exception $e) { - // fall through to inverse attempt - } - try { - $reverse = (float)$this->currencyFactory->create()->load($target)->getRate($source); - if ($reverse > 0) { - return 1 / $reverse; - } - } catch (\Exception $e) { - // fall through to null - } - return null; - } - /** * Buyer country for the rate preview. No admin-side config exists for * this — use the Magento store's base country as a stand-in. Merchant @@ -261,7 +231,7 @@ private function resolveBuyerCountry(?int $storeId): string { $scope = $storeId !== null ? ScopeInterface::SCOPE_STORES : 'default'; $country = (string)$this->scopeConfig->getValue('general/country/default', $scope, $storeId); - return $country !== '' ? strtoupper($country) : 'NO'; + return $country !== '' ? strtoupper($country) : 'NL'; } /** diff --git a/Controller/Payment/Cancel.php b/Controller/Payment/Cancel.php index b2138ff8..1a621346 100755 --- a/Controller/Payment/Cancel.php +++ b/Controller/Payment/Cancel.php @@ -13,6 +13,7 @@ use Magento\Framework\App\ResponseInterface; use Magento\Framework\Exception\LocalizedException; use Two\Gateway\Service\Payment\OrderService; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -25,6 +26,9 @@ class Cancel extends Action */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var OrderService */ @@ -38,10 +42,12 @@ class Cancel extends Action */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, OrderService $orderService, Context $context ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->orderService = $orderService; parent::__construct($context); } @@ -58,7 +64,7 @@ public function execute() $this->orderService->cancelTwoOrder($order); $message = __( 'Your invoice purchase with %1 has been cancelled. The cart will be restored.', - $this->configRepository::PROVIDER + $this->brandRegistry->getProductName() ); throw new LocalizedException($message); } catch (Exception $exception) { diff --git a/Controller/Payment/Confirm.php b/Controller/Payment/Confirm.php index 34a37195..198e1f51 100755 --- a/Controller/Payment/Confirm.php +++ b/Controller/Payment/Confirm.php @@ -15,6 +15,7 @@ use Magento\Framework\App\ResponseInterface; use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Model\Order\Email\Sender\OrderSender; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Service\Payment\OrderService; @@ -28,6 +29,9 @@ class Confirm extends Action */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var AddressRepositoryInterface */ @@ -54,13 +58,15 @@ public function __construct( SearchCriteriaBuilder $searchCriteriaBuilder, OrderService $orderService, OrderSender $orderSender, - ConfigRepository $configRepository + ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry ) { $this->addressRepository = $addressRepository; $this->searchCriteriaBuilder = $searchCriteriaBuilder; $this->orderService = $orderService; $this->orderSender = $orderSender; $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; parent::__construct($context); } @@ -84,7 +90,7 @@ public function execute() } catch (Exception $exception) { $message = __( "Failed to update %1 customer address: %2", - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $exception->getMessage() ); $this->orderService->addOrderComment($order, $message); @@ -94,13 +100,13 @@ public function execute() } else { $comment = __( 'Unable to confirm %1 order with %2 state.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $twoOrder['state'] ?? 'undefined' ); $this->orderService->addOrderComment($order, $comment); $message = __( 'Your invoice purchase with %1 could not be processed. The cart will be restored.', - $this->configRepository::PROVIDER + $this->brandRegistry->getProductName() ); throw new LocalizedException($message); } @@ -179,7 +185,7 @@ private function updateCustomerAddress($order, $twoOrder) $this->addressRepository->save($customerAddress); $message = __( "%1 customer address updated.", - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); $this->orderService->addOrderComment($order, $message); } diff --git a/Controller/Payment/Verificationfailed.php b/Controller/Payment/Verificationfailed.php index 3a5168cb..89a9920e 100755 --- a/Controller/Payment/Verificationfailed.php +++ b/Controller/Payment/Verificationfailed.php @@ -12,6 +12,7 @@ use Magento\Framework\App\Action\Context; use Magento\Framework\Exception\LocalizedException; use Two\Gateway\Service\Payment\OrderService; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -24,6 +25,9 @@ class Verificationfailed extends Action */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var OrderService */ @@ -37,10 +41,12 @@ class Verificationfailed extends Action */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, OrderService $orderService, Context $context ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->orderService = $orderService; parent::__construct($context); } @@ -55,7 +61,7 @@ public function execute() $order = $this->orderService->getOrderByReference(); $message = __( 'Your invoice purchase with %1 failed verification. The cart will be restored.', - $this->configRepository::PROVIDER + $this->brandRegistry->getProductName() ); throw new LocalizedException($message); } catch (Exception $exception) { diff --git a/Model/Config/Backend/SurchargeGrid.php b/Model/Config/Backend/SurchargeGrid.php index ca880514..ccd0496d 100644 --- a/Model/Config/Backend/SurchargeGrid.php +++ b/Model/Config/Backend/SurchargeGrid.php @@ -17,7 +17,9 @@ use Magento\Framework\Model\ResourceModel\AbstractResource; use Magento\Framework\Data\Collection\AbstractDb; use Magento\Store\Model\StoreManagerInterface; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; +use Two\Gateway\Api\CurrencyRatesProviderInterface; /** * Backend model for the surcharge grid. @@ -36,6 +38,12 @@ class SurchargeGrid extends Value /** @var StoreManagerInterface */ private $storeManager; + /** @var CurrencyRatesProviderInterface */ + private $ratesProvider; + + /** @var BrandRegistryInterface */ + private $brandRegistry; + public function __construct( Context $context, Registry $registry, @@ -43,6 +51,8 @@ public function __construct( TypeListInterface $cacheTypeList, WriterInterface $configWriter, StoreManagerInterface $storeManager, + CurrencyRatesProviderInterface $ratesProvider, + BrandRegistryInterface $brandRegistry, AbstractResource $resource = null, AbstractDb $resourceCollection = null, array $data = [] @@ -50,6 +60,8 @@ public function __construct( parent::__construct($context, $registry, $config, $cacheTypeList, $resource, $resourceCollection, $data); $this->configWriter = $configWriter; $this->storeManager = $storeManager; + $this->ratesProvider = $ratesProvider; + $this->brandRegistry = $brandRegistry; } /** @@ -66,8 +78,6 @@ public function beforeSave() */ public function afterSave() { - // Read grid values from the groups POST data (not getValue(), which - // was cleared by beforeSave() to prevent Magento storing the array) $groups = $this->getData('groups'); if (!is_array($groups) || !isset($groups['payment_terms']['fields']['surcharge_grid']['value']) @@ -87,7 +97,7 @@ public function afterSave() $scope = $this->getScope(); $scopeId = (int)$this->getScopeId(); - $maxFixed = ConfigRepository::SURCHARGE_FIXED_MAX; + $maxFixed = $this->getConvertedFixedMax($scope, $scopeId); $maxPercentage = ConfigRepository::SURCHARGE_PERCENTAGE_MAX; foreach ($gridValues as $days => $fields) { @@ -103,7 +113,6 @@ public function afterSave() $path = sprintf('payment/two_payment/surcharge_%d_%s', $days, $type); - // Handle scope inheritance if (isset($inheritData[$days][$type]) && $inheritData[$days][$type]) { $this->configWriter->delete($path, $scope, $scopeId); continue; @@ -123,7 +132,6 @@ public function afterSave() } // Persist the base currency so fixed amounts remain meaningful - // even if the store's default currency changes later $currencyCode = $this->resolveBaseCurrency($scope, $scopeId); $this->configWriter->save( ConfigRepository::XML_PATH_SURCHARGE_FIXED_CURRENCY, @@ -152,7 +160,38 @@ private function resolveBaseCurrency(string $scope, int $scopeId): string } return (string)$this->getFieldsetDataValue('currency/options/base') ?: (string)$this->_config->getValue('currency/options/base') - ?: 'USD'; + ?: 'EUR'; + } + + /** + * Get the fixed max converted to the store's base currency. + */ + /** + * Brand-defined fixed-fee max, converted into the merchant's base + * currency. Returns null when the brand imposes no upper bound; + * validateValue() must skip the upper-bound check in that case. + */ + private function getConvertedFixedMax(string $scope, int $scopeId): ?int + { + $limit = $this->brandRegistry->getSurchargeFixedMax(); + if ($limit === null) { + return null; + } + $limitAmount = (int)$limit['amount']; + $limitCurrency = $limit['currency']; + $baseCurrency = $this->resolveBaseCurrency($scope, $scopeId); + + if ($baseCurrency === $limitCurrency) { + return $limitAmount; + } + + $storeId = ($scope === 'stores' && $scopeId > 0) ? $scopeId : null; + $rate = $this->ratesProvider->getRate($limitCurrency, $baseCurrency, $storeId); + if ($rate !== null && $rate > 0) { + return (int)ceil($limitAmount * $rate); + } + + return $limitAmount; } /** @@ -164,7 +203,7 @@ private function validateValue( string $type, float $value, int $days, - int $maxFixed, + ?int $maxFixed, int $maxPercentage ): void { if ($value < 0) { @@ -172,7 +211,7 @@ private function validateValue( __('%1 days - %2: value cannot be negative.', $days, $type) ); } - if ($type === 'fixed' && $value > $maxFixed) { + if ($type === 'fixed' && $maxFixed !== null && $value > $maxFixed) { throw new LocalizedException( __('%1 days - fixed amount: maximum is %2.', $days, $maxFixed) ); diff --git a/Model/Config/Repository.php b/Model/Config/Repository.php index ecd8a4fe..17d1a431 100755 --- a/Model/Config/Repository.php +++ b/Model/Config/Repository.php @@ -9,11 +9,11 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\ProductMetadataInterface; -use Magento\Framework\App\State; use Magento\Framework\Encryption\EncryptorInterface; use Magento\Framework\UrlInterface; use Magento\Store\Model\ScopeInterface; use Magento\Tax\Model\Calculation as TaxCalculation; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface; /** @@ -37,22 +37,20 @@ class Repository implements RepositoryInterface * @var ProductMetadataInterface */ private $productMetadata; - /** - * @var State - */ - private $appState; /** * @var TaxCalculation */ private $taxCalculation; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @param ScopeConfigInterface $scopeConfig * @param EncryptorInterface $encryptor * @param UrlInterface $urlBuilder * @param ProductMetadataInterface $productMetadata - * @param State $appState * @param TaxCalculation $taxCalculation */ public function __construct( @@ -60,15 +58,15 @@ public function __construct( EncryptorInterface $encryptor, UrlInterface $urlBuilder, ProductMetadataInterface $productMetadata, - State $appState, - TaxCalculation $taxCalculation + TaxCalculation $taxCalculation, + BrandRegistryInterface $brandRegistry ) { $this->scopeConfig = $scopeConfig; $this->encryptor = $encryptor; $this->urlBuilder = $urlBuilder; $this->productMetadata = $productMetadata; - $this->appState = $appState; $this->taxCalculation = $taxCalculation; + $this->brandRegistry = $brandRegistry; } /** @@ -250,7 +248,7 @@ public function getMode(?int $storeId = null): string */ public function getCheckoutApiUrl(?string $mode = null): string { - if ($this->appState->getMode() === State::MODE_DEVELOPER) { + if ($this->isDeveloperMode()) { $envUrl = getenv('TWO_API_BASE_URL'); if ($envUrl !== false && $envUrl !== '') { return $envUrl; @@ -258,7 +256,7 @@ public function getCheckoutApiUrl(?string $mode = null): string } $mode = $mode ?: $this->getMode(); $prefix = $mode == 'production' ? 'api' : ('api.' . $mode); - return sprintf(self::URL_TEMPLATE, $prefix); + return sprintf($this->brandRegistry->getCheckoutUrlTemplate(), $prefix); } /** @@ -266,7 +264,7 @@ public function getCheckoutApiUrl(?string $mode = null): string */ public function getCheckoutPageUrl(?string $mode = null): string { - if ($this->appState->getMode() === State::MODE_DEVELOPER) { + if ($this->isDeveloperMode()) { $envUrl = getenv('TWO_CHECKOUT_BASE_URL'); if ($envUrl !== false && $envUrl !== '') { return $envUrl; @@ -274,30 +272,69 @@ public function getCheckoutPageUrl(?string $mode = null): string } $mode = $mode ?: $this->getMode(); $prefix = $mode == 'production' ? 'checkout' : ('checkout.' . $mode); - return sprintf(self::URL_TEMPLATE, $prefix); + return sprintf($this->brandRegistry->getCheckoutUrlTemplate(), $prefix); + } + + /** + * Check if Magento is in developer mode. + * + * Reads from app/etc/env.php directly to avoid State DI injection + *. Equivalent to State::getMode() === MODE_DEVELOPER. + * + * @return bool + */ + private function isDeveloperMode(): bool + { + if (defined('BP')) { + $envFile = BP . '/app/etc/env.php'; + if (file_exists($envFile)) { + $env = include $envFile; + return ($env['MAGE_MODE'] ?? '') === 'developer'; + } + } + return false; } /** - * Get brand identifier for checkout page. + * Get brand identifier for checkout page URL decoration. + * + * Returns empty in production so URLs stay clean — the checkout + * domain itself conveys the brand there. Only emitted in non-prod + * modes where domains are shared across brands. * * @return string */ public function getBrand(): string { - $envBrand = getenv('TWO_BRAND'); - if ($envBrand !== false && $envBrand !== '') { + if ($this->getMode() === 'production') { + return ''; + } + // Dev-loop override: developers can route a non-prod build to a + // specific brand sub-stack via TWO_BRAND. Sanitise — the value + // goes straight into a query string emitted to the buyer + // browser, so a typo like "TWO_BRAND=foo bar" must not slip + // through. + $envBrand = getenv("TWO_BRAND"); + if ($envBrand !== false && $envBrand !== "" && preg_match("/^[a-z0-9-]+$/i", $envBrand)) { return $envBrand; } - return ''; + return $this->brandRegistry->getBrandTag(); } /** - * Get brand version for checkout page. + * Get brand version for checkout page URL decoration. + * + * Resolved by Makefile: 'qa' for @two.inc gcloud users, empty otherwise. + * Overridable via TWO_BRAND_VERSION in .env.local. Returns empty in + * production — see getBrand(). * * @return string */ public function getBrandVersion(): string { + if ($this->getMode() === 'production') { + return ''; + } $envVersion = getenv('TWO_BRAND_VERSION'); if ($envVersion !== false && $envVersion !== '') { return $envVersion; @@ -456,7 +493,7 @@ public function isSurchargeDifferential(?int $storeId = null): bool public function getSurchargeLineDescription(?int $storeId = null): string { return (string)$this->getConfig(self::XML_PATH_SURCHARGE_LINE_DESCRIPTION, $storeId) - ?: 'Payment terms fee'; + ?: 'Payment terms fee - %1 days'; } /** diff --git a/Model/Config/Source/AvailablePaymentTerms.php b/Model/Config/Source/AvailablePaymentTerms.php index ac96c46d..ed5d07a0 100644 --- a/Model/Config/Source/AvailablePaymentTerms.php +++ b/Model/Config/Source/AvailablePaymentTerms.php @@ -8,6 +8,7 @@ namespace Two\Gateway\Model\Config\Source; use Magento\Framework\Data\OptionSourceInterface; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface; /** @@ -15,13 +16,21 @@ */ class AvailablePaymentTerms implements OptionSourceInterface { + /** @var BrandRegistryInterface */ + private $brandRegistry; + + public function __construct(BrandRegistryInterface $brandRegistry) + { + $this->brandRegistry = $brandRegistry; + } + /** * @inheritDoc */ public function toOptionArray(): array { $options = []; - foreach (RepositoryInterface::AVAILABLE_PAYMENT_TERMS as $days) { + foreach ($this->brandRegistry->getAvailablePaymentTerms() as $days) { $options[] = ['value' => $days, 'label' => __('%1 days', $days)]; } return $options; diff --git a/Model/Config/Source/PaymentTermsDurationDays.php b/Model/Config/Source/PaymentTermsDurationDays.php index 7b8191b6..9a0b2e18 100644 --- a/Model/Config/Source/PaymentTermsDurationDays.php +++ b/Model/Config/Source/PaymentTermsDurationDays.php @@ -11,6 +11,9 @@ /** * Payment Terms Duration Days Source Model + * + * Supported payment terms (in days) depend on the brand's commercial + * agreement — see BrandRegistryInterface::getAvailablePaymentTerms(). */ class PaymentTermsDurationDays implements OptionSourceInterface { @@ -25,10 +28,9 @@ class PaymentTermsDurationDays implements OptionSourceInterface */ public function toOptionArray(): array { - $options = []; - foreach (self::STANDARD_OPTIONS as $days) { - $options[] = ['value' => $days, 'label' => __('%1 days', $days)]; - } - return $options; + // Filter to the brand's supported payment terms via BrandRegistryInterface. + return [ + ['value' => 30, 'label' => __('30 days')] + ]; } } diff --git a/Model/Config/Source/PaymentTermsType.php b/Model/Config/Source/PaymentTermsType.php index 86f76f97..cd8cbee6 100644 --- a/Model/Config/Source/PaymentTermsType.php +++ b/Model/Config/Source/PaymentTermsType.php @@ -11,6 +11,9 @@ /** * Payment Terms Type Source Model + * + * Supported payment term types depend on the brand's commercial + * agreement — see BrandRegistryInterface. */ class PaymentTermsType implements OptionSourceInterface { @@ -22,9 +25,9 @@ class PaymentTermsType implements OptionSourceInterface */ public function toOptionArray(): array { + // Filter to the brand's supported payment term types via BrandRegistryInterface. return [ - ['value' => self::STANDARD, 'label' => __('Standard')], - ['value' => self::END_OF_MONTH, 'label' => __('End of Month')] + ['value' => self::STANDARD, 'label' => __('Standard')] ]; } } diff --git a/Model/CurrencyRatesProvider.php b/Model/CurrencyRatesProvider.php new file mode 100644 index 00000000..f185f6ba --- /dev/null +++ b/Model/CurrencyRatesProvider.php @@ -0,0 +1,93 @@ +currencyFactory = $currencyFactory; + $this->storeManager = $storeManager; + } + + /** + * @inheritDoc + */ + public function getRate(string $fromCurrency, string $toCurrency, ?int $storeId = null): ?float + { + if ($fromCurrency === $toCurrency) { + return 1.0; + } + + $baseCurrency = $this->resolveBaseCurrency($storeId); + $base = $this->loadBaseCurrency($baseCurrency); + if ($base === null) { + return null; + } + + if ($fromCurrency === $baseCurrency) { + $rate = (float)$base->getRate($toCurrency); + return $rate > 0 ? $rate : null; + } + if ($toCurrency === $baseCurrency) { + $rate = (float)$base->getRate($fromCurrency); + return $rate > 0 ? 1.0 / $rate : null; + } + + $rateFrom = (float)$base->getRate($fromCurrency); + $rateTo = (float)$base->getRate($toCurrency); + return ($rateFrom > 0 && $rateTo > 0) ? ($rateTo / $rateFrom) : null; + } + + private function resolveBaseCurrency(?int $storeId): string + { + try { + return (string)$this->storeManager->getStore($storeId)->getBaseCurrencyCode(); + } catch (\Exception $e) { + return ''; + } + } + + /** + * Load the base currency's rate table. Confined to this method so the + * phpstan suppression scope stays minimal. + * + * @return \Magento\Directory\Model\Currency|null + */ + private function loadBaseCurrency(string $baseCurrency) + { + if ($baseCurrency === '') { + return null; + } + try { + return $this->currencyFactory->create()->load($baseCurrency); + } catch (\Exception $e) { + return null; + } + } +} diff --git a/Model/Pdf/Total/Surcharge.php b/Model/Pdf/Total/Surcharge.php new file mode 100644 index 00000000..72e11bdf --- /dev/null +++ b/Model/Pdf/Total/Surcharge.php @@ -0,0 +1,44 @@ +getSource(); + $amount = (float)$source->getDataUsingMethod('two_surcharge_amount'); + $tax = (float)$source->getDataUsingMethod('two_surcharge_tax_amount'); + if ($amount <= 0) { + return []; + } + + $label = $source->getDataUsingMethod('two_surcharge_description') + ?: (string)__('Two Surcharge'); + + $value = $this->getAmountPrefix() . $this->getOrder()->formatPriceTxt($amount + $tax); + + return [[ + 'amount' => $value, + 'label' => $label . ':', + 'font_size' => $this->getFontSize() ?: 7, + ]]; + } +} diff --git a/Model/Total/Creditmemo/Surcharge.php b/Model/Total/Creditmemo/Surcharge.php new file mode 100644 index 00000000..e3326e92 --- /dev/null +++ b/Model/Total/Creditmemo/Surcharge.php @@ -0,0 +1,117 @@ +getOrder(); + + $orderSurcharge = (float)$order->getTwoSurchargeAmount(); + if ($orderSurcharge <= 0) { + return $this; + } + + $alreadyRefunded = (float)$order->getTwoSurchargeRefunded(); + $maxRefundable = $orderSurcharge - $alreadyRefunded; + if ($maxRefundable <= 0) { + return $this; + } + + $baseOrderSurcharge = (float)$order->getBaseTwoSurchargeAmount(); + $baseAlreadyRefunded = (float)$order->getBaseTwoSurchargeRefunded(); + $baseMaxRefundable = $baseOrderSurcharge - $baseAlreadyRefunded; + + // Phase 5 plugin sets `two_surcharge_amount` directly on the + // creditmemo from request data. hasData() distinguishes "explicit + // merchant override" (including 0) from "never set, use default". + // Normalise to 6dp on entry — admin input is parsed by + // CreditmemoSurchargeOverride at locale precision (often 2dp, + // potentially more) and we keep 6dp internally so the refund + // line gross matches what ComposeOrder declared at placement. + // See Model/Total/Surcharge for the 6dp invariant rationale. + if ($creditmemo->hasData('two_surcharge_amount') + && $creditmemo->getData('two_surcharge_amount') !== null + && $creditmemo->getData('two_surcharge_amount') !== '' + ) { + $amount = round((float)$creditmemo->getData('two_surcharge_amount'), 6); + } else { + // Proportional refund — keep at 6dp internally. Previously, + // this rounded at 2dp, losing up to half a cent that defeated + // the Total\Surcharge fix for the dominant (partial-refund) + // case. + $orderSubtotal = (float)$order->getSubtotal(); + $cmSubtotal = (float)$creditmemo->getSubtotal(); + $proportion = $orderSubtotal > 0 ? $cmSubtotal / $orderSubtotal : 0.0; + $amount = round($orderSurcharge * $proportion, 6); + } + + if ($amount <= 0) { + return $this; + } + + $amount = min($amount, $maxRefundable); + + $taxRatePercent = (float)$order->getTwoSurchargeTaxRate(); + $taxAmount = round($amount * ($taxRatePercent / 100), 6); + + // base_to_order_rate = order-currency units per 1 base-currency unit. + // Convert order → base by dividing. + $rate = (float)$order->getBaseToOrderRate(); + if ($rate <= 0) { + // Fallback: derive from the persisted surcharge columns. Mind + // the direction — orderSurcharge / baseOrderSurcharge gives + // order/base (matches base_to_order_rate semantics above). + $rate = $baseOrderSurcharge > 0 ? $orderSurcharge / $baseOrderSurcharge : 1.0; + } + $baseAmount = round($amount / $rate, 6); + $baseAmount = min($baseAmount, $baseMaxRefundable); + $baseTaxAmount = round($taxAmount / $rate, 6); + + $grossAmount = round($amount + $taxAmount, 6); + $baseGrossAmount = round($baseAmount + $baseTaxAmount, 6); + + $creditmemo->setTwoSurchargeAmount($amount); + $creditmemo->setBaseTwoSurchargeAmount($baseAmount); + $creditmemo->setTwoSurchargeTaxAmount($taxAmount); + $creditmemo->setBaseTwoSurchargeTaxAmount($baseTaxAmount); + $creditmemo->setTwoSurchargeDescription((string)$order->getTwoSurchargeDescription()); + $creditmemo->setTwoSurchargeTaxRate($taxRatePercent); + + $creditmemo->setGrandTotal((float)$creditmemo->getGrandTotal() + $grossAmount); + $creditmemo->setBaseGrandTotal((float)$creditmemo->getBaseGrandTotal() + $baseGrossAmount); + $creditmemo->setTaxAmount((float)$creditmemo->getTaxAmount() + $taxAmount); + $creditmemo->setBaseTaxAmount((float)$creditmemo->getBaseTaxAmount() + $baseTaxAmount); + + // NOTE: do NOT mutate $order->setTwoSurchargeRefunded here. collect() + // runs on prepareCreditmemo and again on save/register — mutating the + // order in-place would double-count. The bump is performed by + // Observer\CreditmemoSurchargeRunningTotal on save_after, gated by + // is-new so retries / re-saves don't compound. + + return $this; + } +} diff --git a/Model/Total/Invoice/Surcharge.php b/Model/Total/Invoice/Surcharge.php new file mode 100644 index 00000000..724677f3 --- /dev/null +++ b/Model/Total/Invoice/Surcharge.php @@ -0,0 +1,74 @@ +getOrder(); + + $orderSurcharge = (float)$order->getTwoSurchargeAmount(); + if ($orderSurcharge <= 0) { + return $this; + } + + $alreadyInvoiced = (float)$order->getTwoSurchargeInvoiced(); + $remaining = $orderSurcharge - $alreadyInvoiced; + if ($remaining <= 0) { + return $this; + } + + $baseOrderSurcharge = (float)$order->getBaseTwoSurchargeAmount(); + $baseAlreadyInvoiced = (float)$order->getBaseTwoSurchargeInvoiced(); + $baseRemaining = $baseOrderSurcharge - $baseAlreadyInvoiced; + + $orderTax = (float)$order->getTwoSurchargeTaxAmount(); + $baseOrderTax = (float)$order->getBaseTwoSurchargeTaxAmount(); + // Tax follows the same proportion as the net amount remaining. + $proportion = $orderSurcharge > 0 ? $remaining / $orderSurcharge : 1.0; + $taxAmount = round($orderTax * $proportion, 6); + $baseTaxAmount = round($baseOrderTax * $proportion, 6); + + $grossAmount = $remaining + $taxAmount; + $baseGrossAmount = $baseRemaining + $baseTaxAmount; + + $invoice->setTwoSurchargeAmount($remaining); + $invoice->setBaseTwoSurchargeAmount($baseRemaining); + $invoice->setTwoSurchargeTaxAmount($taxAmount); + $invoice->setBaseTwoSurchargeTaxAmount($baseTaxAmount); + $invoice->setTwoSurchargeDescription((string)$order->getTwoSurchargeDescription()); + $invoice->setTwoSurchargeTaxRate((float)$order->getTwoSurchargeTaxRate()); + + $invoice->setGrandTotal((float)$invoice->getGrandTotal() + $grossAmount); + $invoice->setBaseGrandTotal((float)$invoice->getBaseGrandTotal() + $baseGrossAmount); + $invoice->setTaxAmount((float)$invoice->getTaxAmount() + $taxAmount); + $invoice->setBaseTaxAmount((float)$invoice->getBaseTaxAmount() + $baseTaxAmount); + + // NOTE: do NOT mutate $order->setTwoSurchargeInvoiced here. collect() + // runs once on prepareInvoice and again on register() — mutating the + // order's running total in collect() double-counts. The bump lives in + // Observer\InvoiceSurchargeRunningTotal which fires on save_after with + // an is-new guard, so it's idempotent across recollects + retries. + + return $this; + } +} diff --git a/Model/Total/Surcharge.php b/Model/Total/Surcharge.php index 5f3c75b9..bfb1b0f4 100644 --- a/Model/Total/Surcharge.php +++ b/Model/Total/Surcharge.php @@ -83,6 +83,7 @@ public function collect( if ($paymentMethod && $paymentMethod !== 'two_payment') { $this->logRepository->addDebugLog('TotalCollector: skipped (payment method: ' . $paymentMethod . ')', []); $this->clearSessionSurcharge(); + $this->clearTotalSurcharge($total, $quote); return $this; } @@ -92,6 +93,7 @@ public function collect( if ($surchargeType === SurchargeType::NONE) { $this->logRepository->addDebugLog('TotalCollector: skipped (type=none)', []); $this->clearSessionSurcharge(); + $this->clearTotalSurcharge($total, $quote); return $this; } @@ -99,6 +101,7 @@ public function collect( if ($selectedDays <= 0) { $this->logRepository->addDebugLog('TotalCollector: skipped (no term selected)', []); $this->clearSessionSurcharge(); + $this->clearTotalSurcharge($total, $quote); return $this; } @@ -129,37 +132,76 @@ public function collect( // Surface to checkout so buyer sees the error (e.g. missing FX rate) throw $e; } catch (\Exception $e) { - $this->logRepository->addDebugLog('TotalCollector: calculation failed', [ + // Never silently zero the surcharge on unexpected failures (API down, + // malformed response, etc). Merchant loses revenue if we do; buyer + // would pay without the surcharge line ever appearing. Surface a + // user-facing error and let checkout halt until the API recovers. + $this->logRepository->addErrorLog('TotalCollector: calculation failed', [ 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); $this->clearSessionSurcharge(); - return $this; + throw new \Magento\Framework\Exception\LocalizedException( + __('Unable to calculate payment terms surcharge. Please try again in a moment.'), + $e + ); } - $netAmount = $result['amount']; + $netAmount = round((float)$result['amount'], 6); if ($netAmount <= 0) { $this->logRepository->addDebugLog('TotalCollector: zero surcharge', ['result' => $result]); $this->clearSessionSurcharge(); + $this->clearTotalSurcharge($total, $quote); return $this; } + // Keep 6dp internally; the API outbound boundary distinguishes + // Money fields (gross/net/tax/discount, 2dp), UnitPrice (6dp), + // and Rate (tax_rate, 6dp). 6dp is the upper precision the API + // accepts for any field, so there's no point being more precise + // internally than the wire can carry — and being LESS precise + // (the earlier 2dp truncation, or the interim 4dp) loses + // sub-cent in tax computations and base-currency conversions + // that accumulates into grand_total and produces visible 1-cent + // display drift when the running sum crosses a rounding + // boundary. See internal ticket for the original 2dp→4dp diagnosis; + // the 4dp→6dp bump aligns with the formalised API precision + // contract (Money 2dp / UnitPrice 6dp / Rate 6dp / Quantity 8dp). + // ComposeOrder / ComposeRefund / ComposeCapture / ComposeShipment + // do the per-field outbound rounding via roundAmt(). $taxRate = $result['tax_rate'] / 100; - $taxAmount = round($netAmount * $taxRate, 2); - $grossAmount = $netAmount + $taxAmount; + $taxAmount = round($netAmount * $taxRate, 6); + $grossAmount = round($netAmount + $taxAmount, 6); // Convert to base currency for base_* fields (order totals/tax reports) $baseToQuoteRate = (float)$quote->getBaseToQuoteRate() ?: 1.0; - $baseGrossAmount = round($grossAmount / $baseToQuoteRate, 4); - $baseTaxAmount = round($taxAmount / $baseToQuoteRate, 4); + $baseGrossAmount = round($grossAmount / $baseToQuoteRate, 6); + $baseTaxAmount = round($taxAmount / $baseToQuoteRate, 6); $total->setGrandTotal($grandTotal + $grossAmount); $total->setBaseGrandTotal((float)$total->getBaseGrandTotal() + $baseGrossAmount); $total->setTaxAmount((float)$total->getTaxAmount() + $taxAmount); $total->setBaseTaxAmount((float)$total->getBaseTaxAmount() + $baseTaxAmount); - // Store net on total object for fetch() — tax flows via setTaxAmount above - $total->setData('two_surcharge_net', $netAmount); + // Persist on the quote-address total so sales_convert_quote_address + // fieldset copies the values onto the order at conversion time. + // We deliberately do NOT mirror onto the quote itself — the quote + // collector runs on every shipping/address change, and a clobber on + // a speculative pass (no items, no two_payment, etc.) would zero a + // valid value set by an earlier pass for the placement address. + $baseNetAmount = round($netAmount / $baseToQuoteRate, 6); + $total->setData('two_surcharge_amount', $netAmount); + $total->setData('base_two_surcharge_amount', $baseNetAmount); + $total->setData('two_surcharge_tax_amount', $taxAmount); + $total->setData('base_two_surcharge_tax_amount', $baseTaxAmount); $total->setData('two_surcharge_description', $result['description']); + $total->setData('two_surcharge_tax_rate', $result['tax_rate']); + + // Note: setData/setTitle/setValue on $total here doesn't propagate to + // segment building. Magento's TotalsReader::fetch() builds fresh Total + // instances from each collector's fetch() return value via setData(). + // Title/value must therefore be emitted by fetch(), not set here. + // Session is the only reliable cross-phase channel. // Store in session for ComposeOrder and cross-request persistence $this->checkoutSession->setTwoSurchargeAmount($netAmount); @@ -183,14 +225,13 @@ public function collect( */ public function fetch(Quote $quote, Total $total): array { - // Prefer the total object (same request as collect), fall back to session. + // Read from session: Magento's TotalsReader::fetch() builds fresh Total + // instances from each collector's fetch() return value, so anything + // set on $total in collect() is lost by the time we get here. // Returns net amount — surcharge tax is included in the Tax line via setTaxAmount. - $amount = (float)$total->getData('two_surcharge_net') - ?: (float)$this->checkoutSession->getTwoSurchargeAmount(); + $amount = (float)$this->checkoutSession->getTwoSurchargeAmount(); $this->logRepository->addDebugLog('TotalCollector fetch()', [ - 'totalNet' => (float)$total->getData('two_surcharge_net'), - 'sessionNet' => (float)$this->checkoutSession->getTwoSurchargeAmount(), 'amount' => $amount, ]); @@ -198,15 +239,13 @@ public function fetch(Quote $quote, Total $total): array return []; } - $title = $total->getData('two_surcharge_description') - ?: $this->checkoutSession->getTwoSurchargeDescription() - ?: 'Payment terms fee'; + $title = $this->checkoutSession->getTwoSurchargeDescription() ?: 'Payment terms fee'; - // Title must be a Phrase (object) — TotalsConverter::process() guards - // with is_object($addressTotal->getTitle()) before calling ->render(), - // so plain strings silently render as empty in the segment. return [ 'code' => $this->getCode(), + // TotalsConverter::process() requires title to be a Phrase object + // (checks is_object() then calls ->render()); plain strings are + // dropped and the client-side segment gets an empty title. 'title' => new \Magento\Framework\Phrase((string)$title), 'value' => $amount, ]; @@ -242,4 +281,20 @@ private function clearSessionSurcharge(): void $this->checkoutSession->setTwoSurchargeDescription(''); $this->checkoutSession->setTwoSurchargeTaxRate(0); } + + /** + * Reset the quote-address total fields so a stale surcharge from an + * earlier collect() pass doesn't survive once conditions change. + * Only operates on the in-memory $total (i.e. the quote address row); + * does NOT touch the quote itself — see the comment in collect() above. + */ + private function clearTotalSurcharge(Total $total, Quote $quote): void + { + $total->setData('two_surcharge_amount', 0); + $total->setData('base_two_surcharge_amount', 0); + $total->setData('two_surcharge_tax_amount', 0); + $total->setData('base_two_surcharge_tax_amount', 0); + $total->setData('two_surcharge_description', ''); + $total->setData('two_surcharge_tax_rate', 0); + } } diff --git a/Model/Two.php b/Model/Two.php index dab413a0..1124cc27 100755 --- a/Model/Two.php +++ b/Model/Two.php @@ -28,6 +28,7 @@ use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Status\HistoryFactory; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Service\Api\Adapter; use Two\Gateway\Service\Order\ComposeCapture; @@ -79,6 +80,9 @@ class Two extends AbstractMethod * @var ConfigRepository */ private $configRepository; + + /** @var BrandRegistryInterface */ + private $brandRegistry; /** * @var UrlCookie */ @@ -148,6 +152,7 @@ public function __construct( ExtensionAttributesFactory $extensionFactory, AttributeValueFactory $customAttributeFactory, ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Data $paymentData, ScopeConfigInterface $scopeConfig, Logger $logger, @@ -178,6 +183,7 @@ public function __construct( $data ); $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->urlCookie = $urlCookie; $this->compositeOrder = $composeOrder; $this->composeRefund = $composeRefund; @@ -222,11 +228,11 @@ public function authorize(InfoInterface $payment, $amount) if ($response['status'] !== 'APPROVED') { $this->logRepository->addDebugLog( - sprintf('Order was not accepted by %s', $this->configRepository::PROVIDER), + sprintf('Order was not accepted by %s', $this->brandRegistry->getProductName()), $response ); throw new LocalizedException( - __('Invoice purchase with %1 is not available for this order.', $this->configRepository::PROVIDER) + __('Invoice purchase with %1 is not available for this order.', $this->brandRegistry->getProductName()) ); } @@ -294,7 +300,7 @@ public function getErrorFromResponse(array $response): ?Phrase $tryAgainLater = __('Please try again later.'); $generalError = __( 'Something went wrong with your request to %1. %2', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $tryAgainLater ); if (!$response || !is_array($response)) { @@ -326,7 +332,9 @@ public function getErrorFromResponse(array $response): ?Phrase } } if (count($errs) > 0) { - return __(join(' ', $errs)); + // Wrap as a Phrase without re-running translation: each + // entry in $errs is already __()-translated. + return __('%1', join(' ', $errs)); } } @@ -345,7 +353,7 @@ public function getErrorFromResponse(array $response): ?Phrase // System errors — include trace ID $message = __( 'Your request to %1 failed. Reason: %2', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $reason ); return $this->_getMessageWithTrace($message, $traceID); @@ -366,7 +374,7 @@ public function getFieldNameFromLoc(string $locStr): ?Phrase if ($fieldNames === null) { $fieldNames = [ '["buyer","representative","phone_number"]' => __('Phone Number'), - '["buyer","company","organization_number"]' => __('Company ID'), + '["buyer","company","organization_number"]' => __('KVK number'), '["buyer","representative","first_name"]' => __('First Name'), '["buyer","representative","last_name"]' => __('Last Name'), '["buyer","representative","email"]' => __('Email Address'), @@ -416,7 +424,7 @@ public function cancel(InfoInterface $payment) $comment = __( 'Could not update %1 order status to cancelled. ' . 'Please contact support with order ID %2. Error: %3', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $twoOrderId, $error ); @@ -424,7 +432,7 @@ public function cancel(InfoInterface $payment) } else { $order->addStatusToHistory( $order->getStatus(), - __('%1 order has been marked as cancelled', $this->configRepository::PROVIDER) + __('%1 order has been marked as cancelled', $this->brandRegistry->getProductName()) ); } @@ -459,7 +467,7 @@ public function capture(InfoInterface $payment, $amount) $twoOrderId = $order->getTwoOrderId(); if (!$twoOrderId) { throw new LocalizedException( - __('Could not initiate capture with %1', $this->configRepository::PROVIDER) + __('Could not initiate capture with %1', $this->brandRegistry->getProductName()) ); } @@ -539,12 +547,12 @@ private function parseFulfillResponse(array $response, Order $order): void if (empty($response['remained_order'])) { $comment = __( '%1 order marked as completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } else { $comment = __( '%1 order marked as partially completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } @@ -577,7 +585,7 @@ public function refund(InfoInterface $payment, $amount) $twoOrderId = $order->getTwoOrderId(); if (!$twoOrderId) { throw new LocalizedException( - __('Could not initiate refund with %1', $this->configRepository::PROVIDER), + __('Could not initiate refund with %1', $this->brandRegistry->getProductName()), ); } @@ -603,7 +611,7 @@ public function refund(InfoInterface $payment, $amount) $reason = __('Amount is missing'); $message = __( 'Failed to refund order with %1. Reason: %2', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $reason ); $this->addOrderComment($order, $message); @@ -614,7 +622,7 @@ public function refund(InfoInterface $payment, $amount) $comment = __( 'Successfully refunded order with %1 for order ID: %2. Refund reference: %3', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), $twoOrderId, $response['refund_no'] ); diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index 3226703c..e542c8cc 100755 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -11,10 +11,10 @@ use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Framework\View\Asset\Repository as AssetRepository; use Magento\Store\Model\StoreManagerInterface; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Service\UrlCookie; use Two\Gateway\Service\Api\Adapter; -use Two\Gateway\Service\Order\SurchargeCalculator; use Two\Gateway\Model\Two; /** @@ -27,6 +27,9 @@ class ConfigProvider implements ConfigProviderInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var Two */ @@ -47,11 +50,6 @@ class ConfigProvider implements ConfigProviderInterface */ private $checkoutSession; - /** - * @var SurchargeCalculator - */ - private $surchargeCalculator; - /** * @var StoreManagerInterface */ @@ -59,19 +57,19 @@ class ConfigProvider implements ConfigProviderInterface public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Adapter $adapter, Two $two, AssetRepository $assetRepository, CheckoutSession $checkoutSession, - SurchargeCalculator $surchargeCalculator, StoreManagerInterface $storeManager ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->adapter = $adapter; $this->two = $two; $this->assetRepository = $assetRepository; $this->checkoutSession = $checkoutSession; - $this->surchargeCalculator = $surchargeCalculator; $this->storeManager = $storeManager; } @@ -93,10 +91,9 @@ public function getConfig(): array 'merchant' => $merchant, ]; - $provider = $this->configRepository::PROVIDER; $tryAgainLater = __('Please try again later.'); $soleTraderaccountCouldNotBeVerified = __('Your sole trader account could not be verified.'); - $paymentTerms = __("%1 terms and conditions", $this->configRepository::PROVIDER); + $paymentTerms = __("payment terms"); $brandParams = $this->buildBrandQueryString(); $paymentTermsLink = $this->configRepository->getCheckoutPageUrl() . '/terms' . $brandParams; @@ -123,33 +120,32 @@ public function getConfig(): array 'defaultPaymentTerm' => $this->configRepository->getDefaultPaymentTerm(), 'selectedPaymentTerm' => (int)$this->checkoutSession->getTwoSelectedTerm() ?: $this->configRepository->getDefaultPaymentTerm(), - 'termSurcharges' => $this->getTermSurcharges(), 'currencySymbol' => $this->getCurrencySymbol(), 'surchargeDescription' => $this->configRepository->getSurchargeLineDescription(), 'isPaymentTermsEnabled' => true, - 'redirectMessage' => __( - 'You will be redirected to %1 when you place order.', - $provider - ), 'orderIntentApprovedMessage' => __( 'Your invoice purchase with %1 is likely to be accepted subject to additional checks.', - $provider + $this->brandRegistry->getProductName() + ), + 'orderIntentDeclinedMessage' => __( + 'Your invoice purchase with %1 has been declined.', + $this->brandRegistry->getProductName() ), - 'orderIntentDeclinedMessage' => __('Your invoice purchase with %1 has been declined.', $provider), 'generalErrorMessage' => __( 'Something went wrong with your request to %1. %2', - $provider, + $this->brandRegistry->getProductName(), $tryAgainLater ), 'invalidEmailListMessage' => __('Please ensure that your invoice email address list only contains valid email addresses separated by commas.'), 'paymentTermsMessage' => __( - 'By checking this box, I confirm that I have read and agree to %1.', - sprintf('%s', $paymentTermsLink, $paymentTerms) + 'I accept the %1 and authorize %2 to process my data automatically.', + sprintf('%s', $paymentTermsLink, $paymentTerms), + $this->brandRegistry->getProviderFullName() ), 'termsNotAcceptedMessage' => __('You must accept %1 to place order.', $paymentTerms), 'soleTraderErrorMessage' => __( 'Something went wrong with your request to %1. %2', - $provider, + $this->brandRegistry->getProductName(), $soleTraderaccountCouldNotBeVerified ), ], @@ -157,60 +153,6 @@ public function getConfig(): array ]; } - /** - * Compute surcharges for each available term using the current quote. - * - * @return array days => surcharge amount - */ - private function getTermSurcharges(): array - { - $terms = $this->configRepository->getAllBuyerTerms(); - $surcharges = []; - - try { - $quote = $this->checkoutSession->getQuote(); - $storeId = (int)$quote->getStoreId(); - // Subtract any existing surcharge to avoid circular base - $existingSurcharge = (float)$this->checkoutSession->getTwoSurchargeGross(); - $grandTotal = (float)$quote->getGrandTotal() - $existingSurcharge; - $currency = $quote->getQuoteCurrencyCode() - ?: $this->storeManager->getStore()->getBaseCurrencyCode(); - - $store = $this->storeManager->getStore(); - $country = $store->getConfig('general/country/default') ?: 'NO'; - $billing = $quote->getBillingAddress(); - $shipping = $quote->getShippingAddress(); - if ($billing && $billing->getCountryId()) { - $country = $billing->getCountryId(); - } elseif ($shipping && $shipping->getCountryId()) { - $country = $shipping->getCountryId(); - } - - foreach ($terms as $days) { - try { - $result = $this->surchargeCalculator->calculate( - $grandTotal, - $days, - $country, - $currency, - $storeId - ); - $net = $result['amount']; - $tax = round($net * ($result['tax_rate'] / 100), 2); - $surcharges[$days] = $net; - } catch (\Exception $e) { - $surcharges[$days] = 0.0; - } - } - } catch (\Exception $e) { - foreach ($terms as $days) { - $surcharges[$days] = 0.0; - } - } - - return $surcharges; - } - /** * Get the currency symbol for the current store's display currency. */ @@ -227,7 +169,8 @@ private function getCurrencySymbol(): string /** * Build query string with brand parameters. * - * @return string e.g. "?brand=x&brandVersion=qa" or "" + * @return string e.g. "?brand=&brandVersion=qa" or "" + * where comes from BrandRegistryInterface::getBrandTag(). */ private function buildBrandQueryString(): string { diff --git a/Model/Webapi/Surcharges.php b/Model/Webapi/Surcharges.php new file mode 100644 index 00000000..4d7ea6da --- /dev/null +++ b/Model/Webapi/Surcharges.php @@ -0,0 +1,141 @@ +checkoutSession = $checkoutSession; + $this->configRepository = $configRepository; + $this->surchargeCalculator = $surchargeCalculator; + $this->logRepository = $logRepository; + } + + /** + * @inheritDoc + */ + public function get(string $cartId): string + { + try { + // Session is the auth boundary — $cartId from the URL is + // unverifiable on an anonymous route (UserContextInterface + // does not populate from the customer session cookie when + // the framework skips auth) and is therefore ignored. + $quote = $this->checkoutSession->getQuote(); + + // Force an in-memory collectTotals so the basis we read + // matches what the frontend's totals observable would + // compute. Without this the values are whatever was last + // persisted, which can lag the live state by more than one + // /totals-information step — anything that updated the + // frontend without persisting leaves us computing against + // stale data. Note: in-memory only, the quote is not + // saved — read-only endpoint semantics preserved. + $quote->collectTotals(); + + $storeId = (int)$quote->getStoreId(); + + // Basis = current quote grand_total minus any surcharge + // segment the collector just wrote. Identical to the post- + // mutation recompute in Model\Webapi\TermSelection so chip + // math is server-authoritative across both endpoints. Both + // fields are written by Model\Total\Surcharge::collect() in + // the same pass, so they're consistent. + $basis = (float)$quote->getGrandTotal() + - (float)$this->checkoutSession->getTwoSurchargeGross(); + + if ($basis <= 0) { + // Empty quote, anonymous probe, or fully-discounted + // cart (100% coupon). The fee on a zero basis is zero + // for any term. Return per-term entries with net=0 + // rather than [] so the frontend chips render zero + // values instead of staying in loader state — the + // legitimate full-discount user can still pick a term. + $emptySurcharges = []; + foreach ($this->configRepository->getAllBuyerTerms($storeId) as $days) { + $emptySurcharges[] = ['days' => (int)$days, 'net' => 0.0]; + } + return (string)json_encode(['term_surcharges' => $emptySurcharges]); + } + + $currency = $quote->getQuoteCurrencyCode() + ?: $quote->getStore()->getBaseCurrencyCode(); + + $country = 'NO'; + $billing = $quote->getBillingAddress(); + $shipping = $quote->getShippingAddress(); + if ($billing && $billing->getCountryId()) { + $country = $billing->getCountryId(); + } elseif ($shipping && $shipping->getCountryId()) { + $country = $shipping->getCountryId(); + } + + $surcharges = []; + foreach ($this->configRepository->getAllBuyerTerms($storeId) as $days) { + try { + $result = $this->surchargeCalculator->calculate( + $basis, + $days, + $country, + $currency, + $storeId + ); + $surcharges[] = ['days' => (int)$days, 'net' => (float)$result['amount']]; + } catch (\Exception $e) { + $surcharges[] = ['days' => (int)$days, 'net' => 0.0]; + } + } + + return (string)json_encode(['term_surcharges' => $surcharges]); + } catch (\Exception $e) { + // Don't 500 — frontend treats empty as "stay in loader state". + $this->logRepository->addErrorLog('Surcharges webapi', $e->getMessage()); + return (string)json_encode(['term_surcharges' => []]); + } + } +} diff --git a/Model/Webapi/TermSelection.php b/Model/Webapi/TermSelection.php index 900cf796..276dc766 100644 --- a/Model/Webapi/TermSelection.php +++ b/Model/Webapi/TermSelection.php @@ -8,6 +8,7 @@ namespace Two\Gateway\Model\Webapi; use Magento\Checkout\Model\Session as CheckoutSession; +use Magento\Framework\Exception\InputException; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\CartTotalRepositoryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; @@ -32,7 +33,7 @@ class TermSelection implements TermSelectionInterface /** * @var CartRepositoryInterface */ - private $quoteRepository; + private $cartRepository; /** * @var CartTotalRepositoryInterface @@ -51,13 +52,13 @@ class TermSelection implements TermSelectionInterface public function __construct( CheckoutSession $checkoutSession, - CartRepositoryInterface $quoteRepository, + CartRepositoryInterface $cartRepository, CartTotalRepositoryInterface $cartTotalRepository, ConfigRepository $configRepository, SurchargeCalculator $surchargeCalculator ) { $this->checkoutSession = $checkoutSession; - $this->quoteRepository = $quoteRepository; + $this->cartRepository = $cartRepository; $this->cartTotalRepository = $cartTotalRepository; $this->configRepository = $configRepository; $this->surchargeCalculator = $surchargeCalculator; @@ -68,11 +69,36 @@ public function __construct( */ public function selectTerm(string $cartId, int $termDays): array { + // Session is the auth boundary on this anonymous webapi route — + // $cartId is unverifiable here (UserContextInterface doesn't + // populate when the framework skips auth) and is therefore + // ignored. See internal ticket for the full reasoning that applies to + // both anonymous surcharge endpoints in this module. + $quote = $this->checkoutSession->getQuote(); + // (int)null = 0 if the quote has no store assigned yet (transient + // quote, anonymous probe). getAllBuyerTerms(0) resolves to the + // default scope's terms, which is acceptable: ComposeOrder + // resolves the real store later, and any term valid in the + // default scope is a reasonable validation subset. + $storeId = (int)$quote->getStoreId(); + + // Reject termDays the merchant hasn't configured. + // Without this guard, an anonymous caller can persist any int + // into the session via setTwoSelectedTerm; the persisted value + // then flows through collectTotals → cartRepository->save → + // ComposeOrder, so the order placed on Two's API would + // reference a term the merchant never offered. Validate + // BEFORE any state mutation so an invalid call doesn't poison + // the session even on the throw path. + $allowedTerms = $this->configRepository->getAllBuyerTerms($storeId); + if (!in_array($termDays, $allowedTerms, true)) { + throw new InputException(__('Selected payment term is not available.')); + } + $this->checkoutSession->setTwoSelectedTerm($termDays); - $quote = $this->checkoutSession->getQuote(); $quote->collectTotals(); - $this->quoteRepository->save($quote); + $this->cartRepository->save($quote); // Build totals response $totals = $this->cartTotalRepository->get($quote->getId()); diff --git a/Observer/CreditmemoSurchargeRunningTotal.php b/Observer/CreditmemoSurchargeRunningTotal.php new file mode 100644 index 00000000..f256b8ed --- /dev/null +++ b/Observer/CreditmemoSurchargeRunningTotal.php @@ -0,0 +1,140 @@ +logRepository = $logRepository; + } + + public function execute(Observer $observer): void + { + $creditmemo = $observer->getEvent()->getCreditmemo(); + if (!$creditmemo) { + return; + } + if ($creditmemo->getOrigData('entity_id') !== null) { + return; + } + + $amount = (float)$creditmemo->getTwoSurchargeAmount(); + if ($amount <= 0) { + return; + } + $baseAmount = (float)$creditmemo->getBaseTwoSurchargeAmount(); + + $order = $creditmemo->getOrder(); + if (!$order || !$order->getId()) { + return; + } + + $resource = $order->getResource(); + $connection = $resource->getConnection(); + $table = $resource->getMainTable(); + $delta = number_format($amount, 6, '.', ''); + $baseDelta = number_format($baseAmount, 6, '.', ''); + $orderId = (int)$order->getId(); + + // Two concurrency mechanisms stacked on this UPDATE — different + // invariants, different parts of the statement: + // + // SET — closes the lost-update race. `column + delta` + // is atomic at the InnoDB row-lock level: lock, read, compute, + // write, release in one shot. Concurrent writers serialize + // through the lock and compose correctly (A bumps to X+a, then + // B reads X+a and bumps to X+a+b). NOT a CAS / optimistic-lock + // pattern — a true CAS would read a baseline into PHP and gate + // the UPDATE on `WHERE column = $baseline` with retry-on- + // mismatch. Additive arithmetic has no contended baseline so we + // don't need that. + // + // WHERE — closes the cap-violation race. Even with + // atomic increments, two concurrent writers can both push past + // the order's `two_surcharge_amount` cap if each is admissible + // alone but their sum isn't. The `<=` guards check the post- + // condition: would the result land within the cap? If yes, the + // row matches and the UPDATE fires; if no, 0 rows affected and + // we fall through to the LEAST-clamped fallback below. `<=`, + // not `=`, because the cap is a maximum — any post-state in + // [0, cap] is valid; equality would only permit increments that + // land exactly at the cap, rejecting most legitimate ones. + // + // Both order-currency and base-currency caps guarded + // independently in the AND-chain — FX divergence (one ccy fits, + // the other doesn't) correctly falls through to clamp. + // + // The refund-side analogue of `Observer/InvoiceSurchargeRunningTotal` + // — invariants are identical modulo column name (`refunded` + // vs `invoiced`). + $strictAffected = $connection->update( + $table, + [ + 'two_surcharge_refunded' => new Expression('two_surcharge_refunded + ' . $delta), + 'base_two_surcharge_refunded' => new Expression( + 'base_two_surcharge_refunded + ' . $baseDelta + ), + ], + [ + $connection->quoteInto('entity_id = ?', $orderId), + 'two_surcharge_refunded + ' . $delta . ' <= two_surcharge_amount', + 'base_two_surcharge_refunded + ' . $baseDelta . ' <= base_two_surcharge_amount', + ] + ); + + if ($strictAffected === 0) { + $connection->update( + $table, + [ + 'two_surcharge_refunded' => new Expression( + 'LEAST(two_surcharge_refunded + ' . $delta . ', two_surcharge_amount)' + ), + 'base_two_surcharge_refunded' => new Expression( + 'LEAST(base_two_surcharge_refunded + ' . $baseDelta . ', base_two_surcharge_amount)' + ), + ], + [$connection->quoteInto('entity_id = ?', $orderId)] + ); + $this->logRepository->addLog( + 'Surcharge running total: cap clamped', + [ + 'observer' => 'creditmemo', + 'order_id' => $orderId, + 'creditmemo_id' => $creditmemo->getId(), + 'attempted_delta' => $amount, + 'cap' => (float)$order->getTwoSurchargeAmount(), + 'reason' => 'concurrent over-refund attempt', + ] + ); + } + + // Best-effort in-memory sync. See InvoiceSurchargeRunningTotal + // for why this is optimistic, not authoritative. + $order->setTwoSurchargeRefunded((float)$order->getTwoSurchargeRefunded() + $amount); + $order->setBaseTwoSurchargeRefunded((float)$order->getBaseTwoSurchargeRefunded() + $baseAmount); + } +} diff --git a/Observer/InvoiceSurchargeRunningTotal.php b/Observer/InvoiceSurchargeRunningTotal.php new file mode 100644 index 00000000..5be3292c --- /dev/null +++ b/Observer/InvoiceSurchargeRunningTotal.php @@ -0,0 +1,174 @@ +save()) avoids re- + * firing sales_order_save_after, which would risk re-entering the + * deferred-invoice flow in Observer\SalesOrderSaveAfter. + */ +class InvoiceSurchargeRunningTotal implements ObserverInterface +{ + /** + * @var LogRepository + */ + private $logRepository; + + public function __construct(LogRepository $logRepository) + { + $this->logRepository = $logRepository; + } + + public function execute(Observer $observer): void + { + $invoice = $observer->getEvent()->getInvoice(); + if (!$invoice) { + return; + } + // Only bump on the initial persist of a new invoice. getOrigData + // returns the value loaded from the DB; null means the row didn't + // exist before this save (i.e. this is the create save). + if ($invoice->getOrigData('entity_id') !== null) { + return; + } + + $amount = (float)$invoice->getTwoSurchargeAmount(); + if ($amount <= 0) { + return; + } + $baseAmount = (float)$invoice->getBaseTwoSurchargeAmount(); + + $order = $invoice->getOrder(); + if (!$order || !$order->getId()) { + return; + } + + $resource = $order->getResource(); + $connection = $resource->getConnection(); + $table = $resource->getMainTable(); + $delta = number_format($amount, 6, '.', ''); + $baseDelta = number_format($baseAmount, 6, '.', ''); + $orderId = (int)$order->getId(); + + // Two concurrency mechanisms stacked on this UPDATE — different + // invariants, different parts of the statement: + // + // SET — closes the lost-update race. `column + delta` + // is atomic at the InnoDB row-lock level: lock, read, compute, + // write, release in one shot. Concurrent writers serialize + // through the lock and compose correctly (A bumps to X+a, then + // B reads X+a and bumps to X+a+b). NOT a CAS / optimistic-lock + // pattern — a true CAS would read a baseline into PHP and gate + // the UPDATE on `WHERE column = $baseline` with retry-on- + // mismatch. Additive arithmetic has no contended baseline so we + // don't need that. + // + // WHERE — closes the cap-violation race. Even with + // atomic increments, two concurrent writers can both push past + // the order's `two_surcharge_amount` cap if each is admissible + // alone but their sum isn't. The `<=` guards check the post- + // condition: would the result land within the cap? If yes, the + // row matches and the UPDATE fires; if no, 0 rows affected and + // we fall through to the LEAST-clamped fallback below. `<=`, + // not `=`, because the cap is a maximum — any post-state in + // [0, cap] is valid; equality would only permit increments that + // land exactly at the cap, rejecting most legitimate ones. + // + // Both order-currency and base-currency caps guarded + // independently in the AND-chain — FX divergence (one ccy fits, + // the other doesn't) correctly falls through to clamp. + $strictAffected = $connection->update( + $table, + [ + 'two_surcharge_invoiced' => new Expression('two_surcharge_invoiced + ' . $delta), + 'base_two_surcharge_invoiced' => new Expression( + 'base_two_surcharge_invoiced + ' . $baseDelta + ), + ], + [ + $connection->quoteInto('entity_id = ?', $orderId), + 'two_surcharge_invoiced + ' . $delta . ' <= two_surcharge_amount', + 'base_two_surcharge_invoiced + ' . $baseDelta . ' <= base_two_surcharge_amount', + ] + ); + + if ($strictAffected === 0) { + // Cap would have been exceeded. Issue a clamped UPDATE so + // the running total settles at exactly the cap, and log the + // clamp. Don't throw — the customer's invoice has already + // been persisted in the same transaction and rolling it + // back over an internal accounting discrepancy would be + // worse than the discrepancy itself. The merchant can + // investigate via the log. + $connection->update( + $table, + [ + 'two_surcharge_invoiced' => new Expression( + 'LEAST(two_surcharge_invoiced + ' . $delta . ', two_surcharge_amount)' + ), + 'base_two_surcharge_invoiced' => new Expression( + 'LEAST(base_two_surcharge_invoiced + ' . $baseDelta . ', base_two_surcharge_amount)' + ), + ], + [$connection->quoteInto('entity_id = ?', $orderId)] + ); + $this->logRepository->addLog( + 'Surcharge running total: cap clamped', + [ + 'observer' => 'invoice', + 'order_id' => $orderId, + 'invoice_id' => $invoice->getId(), + 'attempted_delta' => $amount, + 'cap' => (float)$order->getTwoSurchargeAmount(), + 'reason' => 'concurrent over-invoice attempt', + ] + ); + } + + // Keep the in-memory order instance loosely in sync so reads in + // the same request see something plausible. Best-effort only — + // concurrent invoice saves may have advanced the persisted total + // beyond this view, and only the DB value is canonical. + $order->setTwoSurchargeInvoiced((float)$order->getTwoSurchargeInvoiced() + $amount); + $order->setBaseTwoSurchargeInvoiced((float)$order->getBaseTwoSurchargeInvoiced() + $baseAmount); + } +} diff --git a/Observer/QuoteToOrderSurcharge.php b/Observer/QuoteToOrderSurcharge.php new file mode 100644 index 00000000..e389cdcf --- /dev/null +++ b/Observer/QuoteToOrderSurcharge.php @@ -0,0 +1,73 @@ +save(). + * + * Why not sales_convert_quote_to_order? Two interface filters strip our fields: + * 1. ToOrder::convert uses populateWithArray($order, ..., OrderInterface::class) + * which drops non-interface fields. + * 2. QuoteManagement::submitQuote then constructs a SECOND order object via + * orderFactory->create() and merges the ToOrder result into it via + * mergeDataObjects(OrderInterface::class, $outer, $inner) — re-applying + * the same interface filter. Setting fields on the inner order (i.e. on + * the sales_convert_quote_to_order event) is wasted work — they never + * reach the order that gets saved. + * + * sales_model_service_quote_submit_before fires AFTER that merge, with the + * outer (about-to-be-saved) order. Setting data here survives to persistence. + * + * The collector at Model/Total/Surcharge.php deliberately does not mirror onto + * $quote (speculative recollect would clobber a previously-good value), so we + * read from the chosen address. Running-total fields (invoiced/refunded) are + * not copied — they start at zero on a new order. + */ +class QuoteToOrderSurcharge implements ObserverInterface +{ + private const FIELDS = [ + 'two_surcharge_amount', + 'base_two_surcharge_amount', + 'two_surcharge_tax_amount', + 'base_two_surcharge_tax_amount', + 'two_surcharge_description', + 'two_surcharge_tax_rate', + ]; + + public function execute(Observer $observer): void + { + $event = $observer->getEvent(); + $order = $event->getOrder(); + $quote = $event->getQuote(); + + if (!$order || !$quote) { + return; + } + + $address = $quote->isVirtual() ? $quote->getBillingAddress() : $quote->getShippingAddress(); + if (!$address) { + return; + } + + $amount = $address->getData('two_surcharge_amount'); + if ($amount === null || (float)$amount <= 0) { + return; + } + + foreach (self::FIELDS as $field) { + $value = $address->getData($field); + if ($value !== null) { + $order->setData($field, $value); + } + } + } +} diff --git a/Observer/SalesOrderAddressUpdate.php b/Observer/SalesOrderAddressUpdate.php index 5fe4db49..20aefa0c 100755 --- a/Observer/SalesOrderAddressUpdate.php +++ b/Observer/SalesOrderAddressUpdate.php @@ -14,6 +14,7 @@ use Two\Gateway\Model\Two; use Two\Gateway\Service\Api\Adapter; use Two\Gateway\Service\Order\ComposeOrder; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -27,6 +28,9 @@ class SalesOrderAddressUpdate implements ObserverInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var OrderRepositoryInterface */ @@ -51,11 +55,13 @@ class SalesOrderAddressUpdate implements ObserverInterface */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, OrderRepositoryInterface $orderRepository, ComposeOrder $compositeOrder, Adapter $apiAdapter ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->orderRepository = $orderRepository; $this->compositeOrder = $compositeOrder; $this->apiAdapter = $apiAdapter; @@ -99,7 +105,7 @@ public function execute(Observer $observer): self $error ); } else { - $comment = __('Order edit request was accepted by %1', $this->configRepository::PROVIDER); + $comment = __('Order edit request was accepted by %1', $this->brandRegistry->getProductName()); $order->addStatusToHistory($order->getStatus(), $comment->render()); } } catch (Exception $e) { diff --git a/Observer/SalesOrderCancelAfter.php b/Observer/SalesOrderCancelAfter.php new file mode 100644 index 00000000..5a0fdc5e --- /dev/null +++ b/Observer/SalesOrderCancelAfter.php @@ -0,0 +1,107 @@ +orderService = $orderService; + $this->logger = $logger; + $this->brandRegistry = $brandRegistry; + } + + /** + * @param Observer $observer + * @throws LocalizedException + */ + public function execute(Observer $observer) + { + $order = $observer->getEvent()->getOrder(); + if (!$order + || $order->getPayment()->getMethod() !== Two::CODE + || !$order->getTwoOrderId() + ) { + return; + } + + try { + $this->orderService->cancelTwoOrder($order); + } catch (LocalizedException $e) { + // Already user-friendly — let it propagate so the admin sees + // the API's reason. The Magento cancel will not be persisted. + $this->logger->error( + sprintf( + 'Two cancel-sync failed for order %s: %s', + $order->getIncrementId(), + $e->getMessage() + ) + ); + throw $e; + } catch (\Throwable $e) { + // Unexpected (network, bug). Wrap to keep the admin-facing + // message clean while preserving the original for debugging. + $this->logger->error( + sprintf( + 'Two cancel-sync errored unexpectedly for order %s: %s', + $order->getIncrementId(), + $e->getMessage() + ), + ['exception' => $e] + ); + throw new LocalizedException( + __( + 'Could not synchronise the cancellation with %1. The order has not been cancelled. Please try again, or contact support if this persists.', + $this->brandRegistry->getProductName() + ), + $e + ); + } + } +} diff --git a/Observer/SalesOrderSaveAfter.php b/Observer/SalesOrderSaveAfter.php index 0629751c..19588dcf 100755 --- a/Observer/SalesOrderSaveAfter.php +++ b/Observer/SalesOrderSaveAfter.php @@ -8,7 +8,6 @@ namespace Two\Gateway\Observer; use Exception; -use Magento\Framework\DB\Transaction; use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; use Magento\Framework\Exception\LocalizedException; @@ -19,6 +18,8 @@ use Magento\Sales\Model\Order\Invoice; use Magento\Sales\Model\Order\Status\HistoryFactory; use Magento\Sales\Model\Service\InvoiceService; +use Magento\Framework\DB\TransactionFactory; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Model\Two; use Two\Gateway\Service\Api\Adapter; @@ -34,6 +35,9 @@ class SalesOrderSaveAfter implements ObserverInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var Adapter */ @@ -55,9 +59,9 @@ class SalesOrderSaveAfter implements ObserverInterface private $invoiceService; /** - * @var Transaction + * @var TransactionFactory */ - private $transaction; + private $transactionFactory; /** * SalesOrderSaveAfter constructor. @@ -67,22 +71,24 @@ class SalesOrderSaveAfter implements ObserverInterface * @param HistoryFactory $historyFactory * @param OrderStatusHistoryRepositoryInterface $orderStatusHistoryRepository * @param InvoiceService $invoiceService - * @param Transaction $transaction + * @param TransactionFactory $transactionFactory */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Adapter $apiAdapter, HistoryFactory $historyFactory, OrderStatusHistoryRepositoryInterface $orderStatusHistoryRepository, InvoiceService $invoiceService, - Transaction $transaction + TransactionFactory $transactionFactory ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->apiAdapter = $apiAdapter; $this->historyFactory = $historyFactory; $this->orderStatusHistoryRepository = $orderStatusHistoryRepository; $this->invoiceService = $invoiceService; - $this->transaction = $transaction; + $this->transactionFactory = $transactionFactory; } /** @@ -98,16 +104,15 @@ public function execute(Observer $observer) ) { return; } - if ($this->configRepository->getFulfillTrigger() !== 'complete' || !in_array($order->getStatus(), $this->configRepository->getFulfillOrderStatusList()) ) { return; } - // Idempotency gate: once we've created a Magento invoice, the order is - // already fulfilled on Two — do not re-post /fulfillments on subsequent - // saves of the same order. + // Idempotency: once we've created the Magento invoice, we've already + // fulfilled with Two. Subsequent saves of the same order should be + // no-ops here. if ($order->hasInvoices()) { return; } @@ -115,44 +120,34 @@ public function execute(Observer $observer) if (!$this->isWholeOrderShipped($order)) { $error = __( "%1 requires whole order to be shipped before it can be fulfilled.", - $this->configRepository::PROVIDER + $this->brandRegistry->getProductName() ); throw new LocalizedException($error); } $response = $this->apiAdapter->execute( - "/v1/order/" . $order->getTwoOrderId() . "/fulfillments" + "/v1/order/" . $order->getTwoOrderId() . "/fulfillments", ); $this->parseFulfillResponse($response, $order); - $this->createOfflinePaidInvoice($order); - } - - /** - * Create a Magento invoice for the whole order and mark it paid. - * - * Two has already been told to fulfil the order via /fulfillments above, - * so this invoice records that fact in Magento — CAPTURE_OFFLINE prevents - * Two::capture() from re-posting /fulfillments. - * - * @param Order $order - * @throws \Exception - */ - private function createOfflinePaidInvoice(Order $order): void - { + // Two has invoiced the buyer; mirror with a Magento invoice. Use + // CAPTURE_OFFLINE so we do not route back through Two::capture() + // and re-post /fulfillments. Persist only the invoice — we are + // already inside sales_order_save_after, so the order object will + // continue through Magento's existing save lifecycle. $invoice = $this->invoiceService->prepareInvoice($order); - if ($invoice->getGrandTotal() <= 0) { - return; + if ($invoice->getGrandTotal() > 0) { + $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); + $invoice->register(); + $invoice->pay(); + $invoice->setTransactionId( + $response['fulfilled_order']['id'] ?? $order->getPayment()->getLastTransId() + ); + $this->transactionFactory->create() + ->addObject($invoice) + ->save(); } - $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); - $invoice->register(); - $invoice->pay(); - $invoice->setTransactionId($order->getPayment()->getLastTransId()); - $this->transaction - ->addObject($invoice) - ->addObject($invoice->getOrder()) - ->save(); } /** @@ -197,12 +192,12 @@ private function parseFulfillResponse(array $response, Order $order): void if (empty($response['remained_order'])) { $comment = __( '%1 order marked as completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } else { $comment = __( '%1 order marked as partially completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } diff --git a/Observer/SalesOrderShipmentAfter.php b/Observer/SalesOrderShipmentAfter.php index bbad819a..ce7945b3 100755 --- a/Observer/SalesOrderShipmentAfter.php +++ b/Observer/SalesOrderShipmentAfter.php @@ -8,7 +8,6 @@ namespace Two\Gateway\Observer; use Exception; -use Magento\Framework\DB\Transaction; use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; use Magento\Framework\Exception\LocalizedException; @@ -20,6 +19,8 @@ use Magento\Sales\Model\Order\Invoice; use Magento\Sales\Model\Order\Status\HistoryFactory; use Magento\Sales\Model\Service\InvoiceService; +use Magento\Framework\DB\TransactionFactory; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Model\Two; use Two\Gateway\Service\Api\Adapter; @@ -36,6 +37,9 @@ class SalesOrderShipmentAfter implements ObserverInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var Adapter */ @@ -62,9 +66,9 @@ class SalesOrderShipmentAfter implements ObserverInterface private $invoiceService; /** - * @var Transaction + * @var TransactionFactory */ - private $transaction; + private $transactionFactory; /** * SalesOrderShipmentAfter constructor. @@ -75,24 +79,26 @@ class SalesOrderShipmentAfter implements ObserverInterface * @param OrderStatusHistoryRepositoryInterface $orderStatusHistoryRepository * @param ComposeShipment $composeShipment * @param InvoiceService $invoiceService - * @param Transaction $transaction + * @param TransactionFactory $transactionFactory */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Adapter $apiAdapter, HistoryFactory $historyFactory, OrderStatusHistoryRepositoryInterface $orderStatusHistoryRepository, ComposeShipment $composeShipment, InvoiceService $invoiceService, - Transaction $transaction + TransactionFactory $transactionFactory ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->apiAdapter = $apiAdapter; $this->historyFactory = $historyFactory; $this->orderStatusHistoryRepository = $orderStatusHistoryRepository; $this->composeShipment = $composeShipment; $this->invoiceService = $invoiceService; - $this->transaction = $transaction; + $this->transactionFactory = $transactionFactory; } /** @@ -104,70 +110,68 @@ public function execute(Observer $observer) /** @var Order\Shipment $shipment */ $shipment = $observer->getEvent()->getShipment(); $order = $shipment->getOrder(); - if ($order - && $order->getPayment()->getMethod() === Two::CODE - && $order->getTwoOrderId() + if (!$order + || $order->getPayment()->getMethod() !== Two::CODE + || !$order->getTwoOrderId() ) { - if ($this->configRepository->getFulfillTrigger() == 'shipment') { - $payload = []; - $isWholeOrderShipped = $this->isWholeOrderShipped($order); - $isPartialOrder = !$isWholeOrderShipped; - while (true) { - if ($isPartialOrder) { - // partial fulfilment - $payload = [ - 'partial' => $this->composeShipment->execute($shipment, $order), - ]; - } - $response = $this->apiAdapter->execute( - "/v1/order/" . $order->getTwoOrderId() . "/fulfillments", - $payload - ); - - $error = $order->getPayment()->getMethodInstance()->getErrorFromResponse($response); - - if ($error) { - if ($response['error_code'] == 'PARTIAL_ORDER_MISSING_DATA') { - $isPartialOrder = true; - continue; - } - throw new LocalizedException($error); - } - break; - } + return; + } + if ($this->configRepository->getFulfillTrigger() !== 'shipment') { + // 'complete' trigger handles fulfilment in SalesOrderSaveAfter; + // 'invoice' trigger goes through Two::capture(). Nothing to do + // here for those. + return; + } + + $payload = []; + $isWholeOrderShipped = $this->isWholeOrderShipped($order); + $isPartialOrder = !$isWholeOrderShipped; + while (true) { + if ($isPartialOrder) { + $payload = [ + 'partial' => $this->composeShipment->execute($shipment, $order), + ]; + } + $response = $this->apiAdapter->execute( + "/v1/order/" . $order->getTwoOrderId() . "/fulfillments", + $payload + ); + + $error = $order->getPayment()->getMethodInstance()->getErrorFromResponse($response); - $this->parseFulfillResponse($response, $order); - if ($isWholeOrderShipped && !$order->hasInvoices()) { - $this->createOfflinePaidInvoice($order); + if ($error) { + if ($response['error_code'] == 'PARTIAL_ORDER_MISSING_DATA') { + $isPartialOrder = true; + continue; } + throw new LocalizedException($error); } + break; } - } - /** - * Create a Magento invoice for the whole order and mark it paid. - * - * Two has already been told to fulfil the order via /fulfillments above, - * so this invoice records that fact in Magento — CAPTURE_OFFLINE prevents - * Two::capture() from re-posting /fulfillments. - * - * @param Order $order - * @throws \Exception - */ - private function createOfflinePaidInvoice(Order $order): void - { - $invoice = $this->invoiceService->prepareInvoice($order); - if ($invoice->getGrandTotal() <= 0) { - return; + $this->parseFulfillResponse($response, $order); + + // Two has now invoiced the buyer. Create + pay the Magento invoice + // to mirror that, but only on whole-order shipment. Partial Magento + // invoicing is not in scope: we let the Two-side partial fulfilment + // stand and create the Magento invoice on the final shipment. + // CAPTURE_OFFLINE is critical — CAPTURE_ONLINE would route through + // Two::capture() and post /fulfillments a second time. + if ($isWholeOrderShipped && !$order->hasInvoices()) { + $invoice = $this->invoiceService->prepareInvoice($order); + if ($invoice->getGrandTotal() > 0) { + $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); + $invoice->register(); + $invoice->pay(); + $invoice->setTransactionId( + $response['fulfilled_order']['id'] ?? $order->getPayment()->getLastTransId() + ); + $this->transactionFactory->create() + ->addObject($invoice) + ->addObject($order) + ->save(); + } } - $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); - $invoice->register(); - $invoice->pay(); - $invoice->setTransactionId($order->getPayment()->getLastTransId()); - $this->transaction - ->addObject($invoice) - ->addObject($invoice->getOrder()) - ->save(); } /** @@ -206,12 +210,12 @@ private function parseFulfillResponse(array $response, Order $order): void if (empty($response['remained_order'])) { $comment = __( '%1 order marked as completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } else { $comment = __( '%1 order marked as partially completed.', - $this->configRepository::PROVIDER, + $this->brandRegistry->getProductName(), ); } diff --git a/Plugin/Api/SurchargeExtensionAttributes.php b/Plugin/Api/SurchargeExtensionAttributes.php new file mode 100644 index 00000000..8d910937 --- /dev/null +++ b/Plugin/Api/SurchargeExtensionAttributes.php @@ -0,0 +1,142 @@ +orderExtensionFactory = $orderExtensionFactory; + $this->invoiceExtensionFactory = $invoiceExtensionFactory; + $this->creditmemoExtensionFactory = $creditmemoExtensionFactory; + } + + public function afterGet($subject, $entity) + { + $this->populate($entity); + return $entity; + } + + public function afterGetList($subject, $result) + { + if ($result instanceof SearchResults || method_exists($result, 'getItems')) { + foreach ($result->getItems() as $item) { + $this->populate($item); + } + } + return $result; + } + + public function afterSave($subject, $entity) + { + $this->populate($entity); + return $entity; + } + + private function populate(ExtensibleDataInterface $entity): void + { + if ($entity instanceof OrderInterface) { + $extension = $entity->getExtensionAttributes() ?: $this->orderExtensionFactory->create(); + $this->copyFields($entity, $extension, self::ORDER_FIELDS); + $entity->setExtensionAttributes($extension); + return; + } + if ($entity instanceof InvoiceInterface) { + $extension = $entity->getExtensionAttributes() ?: $this->invoiceExtensionFactory->create(); + $this->copyFields($entity, $extension, self::INVOICE_FIELDS); + $entity->setExtensionAttributes($extension); + return; + } + if ($entity instanceof CreditmemoInterface) { + $extension = $entity->getExtensionAttributes() ?: $this->creditmemoExtensionFactory->create(); + $this->copyFields($entity, $extension, self::CREDITMEMO_FIELDS); + $entity->setExtensionAttributes($extension); + } + } + + /** + * Copy each field from the entity (via the magic getter on the data + * object) onto the extension attributes object (via its setter). + */ + private function copyFields(ExtensibleDataInterface $entity, $extension, array $fields): void + { + foreach ($fields as $field) { + $value = $entity->getData($field); + if ($value === null) { + continue; + } + $setter = 'set' . str_replace('_', '', ucwords($field, '_')); + if (method_exists($extension, $setter)) { + $extension->{$setter}($value); + } + } + } +} diff --git a/Plugin/Model/Sales/CreditmemoSurchargeOverride.php b/Plugin/Model/Sales/CreditmemoSurchargeOverride.php new file mode 100644 index 00000000..4b73f878 --- /dev/null +++ b/Plugin/Model/Sales/CreditmemoSurchargeOverride.php @@ -0,0 +1,130 @@ +request = $request; + $this->localeFormat = $localeFormat; + } + + /** + * @param Creditmemo $subject + * @return null + */ + public function beforeCollectTotals(Creditmemo $subject) + { + // Scope to the admin creditmemo create/save actions so an arbitrary + // controller that happens to construct a Creditmemo via DI in the + // same request can't have a `creditmemo[*]` query string injected + // into its totals collection. + $controller = $this->request->getControllerName(); + $action = $this->request->getActionName(); + $isCreditmemoSave = $controller === 'order_creditmemo' + && in_array($action, ['save', 'updateQty'], true); + if (!$isCreditmemoSave) { + return null; + } + + $data = $this->request->getParam('creditmemo'); + if (!is_array($data) || !array_key_exists('two_surcharge_amount', $data)) { + return null; + } + + $raw = $data['two_surcharge_amount']; + if ($raw === '' || $raw === null) { + return null; + } + + // Strip leading/trailing horizontal whitespace including U+00A0 + // (NBSP) — currency-paste from nl_NL displays like "€ 1,50" + // resolves to "\xc2\xa01,50" once the symbol is removed. + // Magento\Framework\Locale\Format::getNumber strips regular + // spaces but NOT NBSP, so it would silently return 0.0 — the + // exact failure mode the regex pre-check is meant to catch. + // Trim once here, then both validation and parsing see the + // same canonical string. + $trimmed = is_scalar($raw) + ? (string)preg_replace('/^\h+|\h+$/u', '', (string)$raw) + : ''; + + // Accept both en_* ("1.50") and nl_* ("1,50") decimal separators + // — the admin's locale governs what they type. Plain is_numeric + // rejects "1,50" and breaks nl_NL admins. The regex pre-check is + // necessary because FormatInterface::getNumber() returns 0.0 + // silently for unparseable strings (e.g. "abc"), which would + // otherwise be indistinguishable from a legitimate "0" entry. + // [-+] preserves the leading-sign tolerance the previous + // is_numeric had. No thousands-separator support — refund + // surcharges are small by construction. + if (!is_scalar($raw) + || !preg_match('/^[-+]?(?:\d+(?:[.,]\d+)?|[.,]\d+)$/', $trimmed) + ) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Refund Two Surcharge must be a valid amount (e.g. 1.50 or 1,50).') + ); + } + $value = (float)$this->localeFormat->getNumber($trimmed); + if ($value < 0) { + throw new \Magento\Framework\Exception\LocalizedException( + __('Refund Two Surcharge cannot be negative.') + ); + } + + // Validate against the order's remaining refundable surcharge so the + // merchant gets an explicit error instead of a silent cap when their + // typed value exceeds what's available. Allow a 1-cent fuzz so + // pre-filled defaults that round-tripped through display don't trip. + $order = $subject->getOrder(); + if ($order && (float)$order->getTwoSurchargeAmount() > 0) { + $maxRefundable = (float)$order->getTwoSurchargeAmount() + - (float)$order->getTwoSurchargeRefunded(); + if ($value - $maxRefundable > 0.01) { + throw new \Magento\Framework\Exception\LocalizedException( + __( + 'Refund Two Surcharge (%1) exceeds the remaining refundable surcharge (%2).', + $value, + max(0.0, $maxRefundable) + ) + ); + } + } + + $subject->setData('two_surcharge_amount', $value); + return null; + } +} diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/Service/Api/Adapter.php b/Service/Api/Adapter.php index e397768e..7235f490 100755 --- a/Service/Api/Adapter.php +++ b/Service/Api/Adapter.php @@ -10,6 +10,7 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\HTTP\Client\Curl; use Throwable; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Api\Log\RepositoryInterface as LogRepository; use Two\Gateway\Api\Webapi\SoleTraderInterface; @@ -24,6 +25,9 @@ class Adapter */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var Curl */ @@ -43,10 +47,12 @@ class Adapter */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, Curl $curlClient, LogRepository $logRepository ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->curlClient = $curlClient; $this->logRepository = $logRepository; } @@ -123,7 +129,7 @@ public function execute( 'Invalid API response.' ); throw new LocalizedException( - __('Invalid API response from %1.', $this->configRepository::PROVIDER) + __('Invalid API response from %1.', $this->brandRegistry->getProductName()) ); } } diff --git a/Service/Order.php b/Service/Order.php index 801b0fb1..ec4e2ce1 100755 --- a/Service/Order.php +++ b/Service/Order.php @@ -178,7 +178,7 @@ public function getLineItemsOrder(OrderModel $order): array 'net_amount' => $this->roundAmt($this->getNetAmountItem($item)), 'tax_amount' => $this->roundAmt($this->getTaxAmountItem($item)), 'discount_amount' => $this->roundAmt($this->getDiscountAmountItem($item)), - 'tax_rate' => $this->roundAmt($this->getTaxRateItem($item)), + 'tax_rate' => $this->roundAmt($this->getTaxRateItem($item), 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($item->getTaxPercent()) . '%', 'unit_price' => $this->roundAmt($this->getUnitPriceItem($item), 6), 'quantity' => $item->getQtyOrdered(), @@ -358,7 +358,7 @@ public function getShippingLineOrder(OrderModel $order): array 'net_amount' => $this->roundAmt($this->getNetAmountShipping($order)), 'tax_amount' => $this->roundAmt($this->getTaxAmountShipping($order)), 'discount_amount' => $this->roundAmt($this->getDiscountAmountShipping($order)), - 'tax_rate' => $this->roundAmt($this->getTaxRateShipping($order)), + 'tax_rate' => $this->roundAmt($this->getTaxRateShipping($order), 6), 'unit_price' => $this->roundAmt($this->getUnitPriceShipping($order), 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($this->getTaxRateShipping($order) * 100) . '%', 'quantity' => 1, @@ -509,7 +509,7 @@ public function getTaxSubtotals(array $lineItems): ?array $summary[] = [ 'taxable_amount' => $this->roundAmt($taxableAmount), 'tax_amount' => $this->roundAmt($taxAmount), - 'tax_rate' => $this->roundAmt($taxRate) + 'tax_rate' => $this->roundAmt($taxRate, 6) ]; } diff --git a/Service/Order/ComposeCapture.php b/Service/Order/ComposeCapture.php index c9c6f53d..15e0d957 100755 --- a/Service/Order/ComposeCapture.php +++ b/Service/Order/ComposeCapture.php @@ -66,7 +66,7 @@ public function getLineItemsInvoice(Order\Invoice $invoice, Order $order): array 'discount_amount' => $this->roundAmt($this->getDiscountAmountItem($item)), 'tax_amount' => $this->roundAmt($this->getTaxAmountItem($item)), 'tax_class_name' => 'VAT ' . $this->roundAmt($orderItem->getTaxPercent()) . '%', - 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100)), + 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100), 6), 'unit_price' => $this->roundAmt($this->getUnitPriceItem($item), 6), 'quantity' => $item->getQty(), 'quantity_unit' => $this->configRepository->getWeightUnit((int)$order->getStoreId()), @@ -99,7 +99,7 @@ public function getLineItemsInvoice(Order\Invoice $invoice, Order $order): array 'net_amount' => $this->roundAmt($this->getNetAmountShipping($order)), 'tax_amount' => $this->roundAmt((float)$order->getShippingTaxAmount()), 'discount_amount' => $this->roundAmt($this->getDiscountAmountShipping($order)), - 'tax_rate' => $this->roundAmt((1.0 * $order->getShippingTaxAmount() / $order->getShippingAmount())), + 'tax_rate' => $this->roundAmt((1.0 * $order->getShippingTaxAmount() / $order->getShippingAmount()), 6), 'unit_price' => $this->roundAmt($this->getUnitPriceShipping($order), 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($taxRate * 100) . '%', 'quantity' => 1, diff --git a/Service/Order/ComposeOrder.php b/Service/Order/ComposeOrder.php index 406d5a24..a3e80d6d 100755 --- a/Service/Order/ComposeOrder.php +++ b/Service/Order/ComposeOrder.php @@ -58,13 +58,30 @@ public function execute(Order $order, string $orderReference, array $additionalD // Fetch line items from the order $lineItems = $this->getLineItemsOrder($order); - // Read surcharge from session (calculated by Total Collector during collectTotals) - $surchargeAmount = (float)$this->checkoutSession->getTwoSurchargeAmount(); - $surchargeTax = (float)$this->checkoutSession->getTwoSurchargeTax(); + // Prefer the persisted order columns (populated by the conversion + // fieldset) over the session. Session is the source of truth for the + // chip-render flow before placement, but by the time ComposeOrder + // runs the order has been converted from the quote and the columns + // are authoritative. Session can drift if multi-tab logout / GC / + // a custom plugin clears it between collectTotals and place(). + $surchargeAmount = (float)$order->getTwoSurchargeAmount(); + $surchargeTax = (float)$order->getTwoSurchargeTaxAmount(); + $description = (string)$order->getTwoSurchargeDescription(); + $taxRatePercent = (float)$order->getTwoSurchargeTaxRate(); + + if ($surchargeAmount <= 0) { + // Fallback to session for orders placed in the brief window where + // a buyer's order conversion runs without the columns populated + // (e.g. mid-deploy before the data patch lands). Remove this + // fallback once we're confident every placement persists. + $surchargeAmount = (float)$this->checkoutSession->getTwoSurchargeAmount(); + $surchargeTax = (float)$this->checkoutSession->getTwoSurchargeTax(); + $description = $this->checkoutSession->getTwoSurchargeDescription() ?: ''; + $taxRatePercent = (float)$this->checkoutSession->getTwoSurchargeTaxRate(); + } if ($surchargeAmount > 0) { - $description = $this->checkoutSession->getTwoSurchargeDescription() ?: 'Payment terms fee'; - $taxRatePercent = (float)$this->checkoutSession->getTwoSurchargeTaxRate(); + $description = $description ?: 'Payment terms fee'; $taxRate = $taxRatePercent / 100; $lineItems[] = [ @@ -78,7 +95,7 @@ public function execute(Order $order, string $orderReference, array $additionalD 'net_amount' => $this->roundAmt($surchargeAmount), 'tax_amount' => $this->roundAmt($surchargeTax), 'discount_amount' => '0.00', - 'tax_rate' => $this->roundAmt($taxRate), + 'tax_rate' => $this->roundAmt($taxRate, 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($taxRatePercent) . '%', 'unit_price' => $this->roundAmt($surchargeAmount, 6), 'quantity' => 1, diff --git a/Service/Order/ComposeRefund.php b/Service/Order/ComposeRefund.php index fff30635..d4656a96 100755 --- a/Service/Order/ComposeRefund.php +++ b/Service/Order/ComposeRefund.php @@ -31,9 +31,15 @@ class ComposeRefund extends OrderService public function execute(Creditmemo $creditmemo, float $amount, Order $order): array { $lineItems = array_values($this->getLineItemsCreditmemo($order, $creditmemo)); - $grossAmount = $this->getSum($lineItems, 'gross_amount'); + // Use creditmemo->getGrandTotal() rather than re-summing line items. + // It's the canonical post-collector refund value Magento records + // and avoids per-line 2dp-rounding drift that re-summing would + // accumulate (worst case ~N * 0.005). Line items now do carry the + // adjustment via an OTHER-type line, so amount and + // sum(line_items.gross_amount) reconcile to within rounding — + // but grand_total remains the source of truth. $result = [ - 'amount' => $grossAmount, + 'amount' => $this->roundAmt((float)$creditmemo->getGrandTotal()), 'currency' => $order->getOrderCurrencyCode(), 'line_items' => $lineItems, ]; @@ -78,7 +84,7 @@ public function getLineItemsCreditmemo(Order $order, Creditmemo $creditmemo): ar 'tax_amount' => $taxAmount, 'discount_amount' => $this->roundAmt($this->getDiscountAmountItem($orderItem) * $part), 'unit_price' => $this->roundAmt($this->getUnitPriceItem($orderItem), 6), - 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100)), + 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100), 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($orderItem->getTaxPercent()) . '%', 'quantity' => $item->getQty(), 'quantity_unit' => $this->configRepository->getWeightUnit((int)$order->getStoreId()), @@ -97,6 +103,100 @@ public function getLineItemsCreditmemo(Order $order, Creditmemo $creditmemo): ar ]; } + if ((float)$creditmemo->getTwoSurchargeAmount() > 0) { + // Match ComposeOrder's component-rounding pattern (each + // value rounded independently to 2dp from the 6dp stamped + // source, gross computed from the unrounded sum). Pre- + // rounding net and tax then summing would diverge from + // ComposeOrder by a cent at half-cent boundaries — the + // resulting refund line gross would mismatch the order + // line gross and Two would reject the refund. See internal ticket. + $netAmountRaw = (float)$creditmemo->getTwoSurchargeAmount(); + $taxAmountRaw = (float)$creditmemo->getTwoSurchargeTaxAmount(); + $netAmount = $this->roundAmt($netAmountRaw); + $taxAmount = $this->roundAmt($taxAmountRaw); + $grossAmount = $this->roundAmt($netAmountRaw + $taxAmountRaw); + $taxRatePercent = (float)$creditmemo->getTwoSurchargeTaxRate(); + $description = (string)$creditmemo->getTwoSurchargeDescription() ?: 'Payment terms fee'; + + // order_item_id 'surcharge' must match ComposeOrder so Two's API + // allocates the refund to the BUYER_FEE line on the original order. + $items['surcharge'] = [ + 'order_item_id' => 'surcharge', + 'name' => $description, + 'description' => $description, + 'type' => 'BUYER_FEE', + 'image_url' => '', + 'product_page_url' => '', + 'gross_amount' => $grossAmount, + 'net_amount' => $netAmount, + 'tax_amount' => $taxAmount, + 'discount_amount' => '0.00', + 'unit_price' => $this->roundAmt($netAmountRaw, 6), + 'tax_rate' => $this->roundAmt($taxRatePercent / 100, 6), + 'tax_class_name' => 'VAT ' . $this->roundAmt($taxRatePercent) . '%', + 'quantity' => 1, + 'quantity_unit' => 'sc', + ]; + } + + // OTHER-type adjustment line. Magento's "Adjustment + // Refund" and "Adjustment Fee" admin inputs feed + // creditmemo.grand_total but had no representation in line_items + // pre-fix, leaving payload.amount and + // sum(line_items.gross_amount) divergent. Combine as a single + // signed line: positive = extra refund to customer, negative = + // fee withheld. + // + // tax_rate '0.00' here is a CONSERVATIVE DEFAULT, not a + // verified fact. Magento has no adjustment_tax accessor; + // Total\Tax ignores the adjustment fields; Total\Grand adds + // adjustment_positive - adjustment_negative straight into + // grand_total without tax decomposition. The same admin field + // is used for two different intents that Magento can't + // distinguish: + // + // (a) tax-exempt amounts (gift-card refunds, goodwill + // credits, write-offs) — 0% is exactly right; + // (b) tax-inclusive partial refunds (typical in EU stores + // with tax-inclusive pricing) where the merchant + // implicitly meant the value to include VAT — 0% + // under-declares recoverable VAT. + // + // We default to (a) because under-declaration is recoverable + // in accounting reconciliation, whereas over-declaration + // (claiming VAT recovery on amounts that may not have been + // taxed) is a tax-authority risk. Merchants whose adjustment + // usage is systematically (b) should track the VAT portion + // separately; revisit with a config knob if that becomes a + // pattern. + // + // Threshold uses the pre-format magnitude (not a post-format + // string compare against "0.00") to avoid number_format + // returning "-0.00" for inputs in (-0.005, 0). + $adjustmentNet = (float)$creditmemo->getAdjustmentPositive() + - (float)$creditmemo->getAdjustmentNegative(); + if (abs($adjustmentNet) >= 0.005) { + $gross = $this->roundAmt($adjustmentNet); + $items['adjustment'] = [ + 'order_item_id' => 'adjustment', + 'name' => 'Adjustment', + 'description' => 'Adjustment', + 'type' => 'OTHER', + 'image_url' => '', + 'product_page_url' => '', + 'gross_amount' => $gross, + 'net_amount' => $gross, + 'tax_amount' => '0.00', + 'discount_amount' => '0.00', + 'unit_price' => $this->roundAmt($adjustmentNet, 6), + 'tax_rate' => '0.00', + 'tax_class_name' => 'VAT 0%', + 'quantity' => 1, + 'quantity_unit' => 'sc', + ]; + } + if (!$order->getIsVirtual() && $creditmemo->getShippingAmount()) { $grossAmount = $this->roundAmt($this->getGrossAmountShipping($creditmemo)); @@ -114,7 +214,7 @@ public function getLineItemsCreditmemo(Order $order, Creditmemo $creditmemo): ar 'tax_amount' => $taxAmount, 'discount_amount' => $this->roundAmt($this->getDiscountAmountShipping($creditmemo)), 'unit_price' => $this->roundAmt($this->getUnitPriceShipping($creditmemo), 6), - 'tax_rate' => $this->roundAmt($this->getTaxRateShipping($creditmemo)), + 'tax_rate' => $this->roundAmt($this->getTaxRateShipping($creditmemo), 6), 'tax_class_name' => 'VAT ' . $this->roundAmt($this->getTaxRateShipping($creditmemo) * 100) . '%', 'quantity' => 1, 'quantity_unit' => 'sc', diff --git a/Service/Order/ComposeShipment.php b/Service/Order/ComposeShipment.php index 3529b2a4..4bd3fbc0 100755 --- a/Service/Order/ComposeShipment.php +++ b/Service/Order/ComposeShipment.php @@ -74,7 +74,7 @@ public function getLineItemsShipment(Order $order, Order\Shipment $shipment): ar 'tax_amount' => $taxAmount, 'discount_amount' => $this->roundAmt($this->getDiscountAmountItem($orderItem) * $part), 'tax_class_name' => 'VAT ' . $this->roundAmt($orderItem->getTaxPercent()) . '%', - 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100)), + 'tax_rate' => $this->roundAmt(($orderItem->getTaxPercent() / 100), 6), 'unit_price' => $this->roundAmt($this->getUnitPriceItem($orderItem), 6), 'quantity' => $item->getQty(), 'quantity_unit' => $this->configRepository->getWeightUnit((int)$order->getStoreId()), diff --git a/Service/Order/SurchargeCalculator.php b/Service/Order/SurchargeCalculator.php index 15a69573..da28da77 100644 --- a/Service/Order/SurchargeCalculator.php +++ b/Service/Order/SurchargeCalculator.php @@ -7,21 +7,21 @@ namespace Two\Gateway\Service\Order; -use Magento\Directory\Model\CurrencyFactory; use Magento\Framework\Exception\LocalizedException; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; +use Two\Gateway\Api\CurrencyRatesProviderInterface; use Two\Gateway\Api\Log\RepositoryInterface as LogRepository; use Two\Gateway\Model\Config\Source\SurchargeType; use Two\Gateway\Service\Api\Adapter; /** - * Delegates surcharge calculation to the Two pricing API. + * Resolves the buyer surcharge for a given order and selected term by + * delegating all arithmetic to POST /v1/pricing/order/fee. The plugin + * maps merchant config onto the request's buyer_fee_share block and + * uses the response's buyer_fee_share field as the final surcharge. * - * Plugin maps merchant surcharge config to a buyer_fee_share object - * on POST /v1/pricing/order/fee. The API applies percentage, fixed, - * cap, and differential (via reference_terms) and returns the final - * buyer-facing fee. Plugin does no fee arithmetic — it only - * FX-converts merchant-config amounts into order currency before send. + * Differential pricing is expressed to the API via reference_terms; + * the plugin never makes a second call to compute a delta. */ class SurchargeCalculator { @@ -41,38 +41,43 @@ class SurchargeCalculator private $logRepository; /** - * @var CurrencyFactory + * @var CurrencyRatesProviderInterface */ - private $currencyFactory; + private $ratesProvider; /** - * @var array In-memory cache for pricing API responses, keyed on request params. + * Request-scoped cache of resolved surcharges, keyed on the public + * calculate() inputs. The pricing endpoint is side-effect-free and + * callers (total collector, ConfigProvider, TermSelection) repeat + * identical calls within a single request; the cache dedupes those. + * + * @var array */ - private $feeCache = []; + private $responseCache = []; public function __construct( ConfigRepository $configRepository, Adapter $apiAdapter, LogRepository $logRepository, - CurrencyFactory $currencyFactory + CurrencyRatesProviderInterface $ratesProvider ) { $this->configRepository = $configRepository; $this->apiAdapter = $apiAdapter; $this->logRepository = $logRepository; - $this->currencyFactory = $currencyFactory; + $this->ratesProvider = $ratesProvider; } /** - * Calculate the buyer's surcharge for a given order and selected term. + * Resolve the buyer surcharge for a given order and selected term. * - * @param float $grossAmount Order gross amount - * @param int $selectedTermDays The term the buyer selected + * @param float $grossAmount Order gross amount, in $orderCurrency + * @param int $selectedTermDays Term the buyer selected * @param string $buyerCountry ISO Alpha-2 country code - * @param string $orderCurrency ISO currency code of the order + * @param string $orderCurrency ISO 4217 currency code of the order * @param int|null $storeId * * @return array{amount: float, tax_rate: float, description: string} - * @throws LocalizedException if fixed-fee currency conversion fails + * @throws LocalizedException when FX rate is missing or API response is malformed */ public function calculate( float $grossAmount, @@ -81,56 +86,80 @@ public function calculate( string $orderCurrency, ?int $storeId = null ): array { + $cacheKey = md5(serialize([$grossAmount, $selectedTermDays, $buyerCountry, $orderCurrency, $storeId])); + if (isset($this->responseCache[$cacheKey])) { + return $this->responseCache[$cacheKey]; + } + $surchargeType = $this->configRepository->getSurchargeType($storeId); if ($surchargeType === SurchargeType::NONE) { - return ['amount' => 0.0, 'tax_rate' => 0.0, 'description' => '']; + return $this->responseCache[$cacheKey] = ['amount' => 0.0, 'tax_rate' => 0.0, 'description' => '']; } - $isDifferential = $this->configRepository->isSurchargeDifferential($storeId); - if ($isDifferential - && $selectedTermDays === $this->configRepository->getDefaultPaymentTerm($storeId) - ) { - // Default term in differential mode — no surcharge, skip API call. - return [ - 'amount' => 0.0, - 'tax_rate' => $this->configRepository->getSurchargeTaxRate($storeId), - 'description' => $this->buildDescription($selectedTermDays, $storeId), - ]; + $buyerFeeShare = $this->buildBuyerFeeShare($surchargeType, $selectedTermDays, $orderCurrency, $storeId); + + $response = $this->apiAdapter->execute('/v1/pricing/order/fee', [ + 'buyer_country_code' => $buyerCountry, + 'approved_on_recourse' => false, + 'currency' => $orderCurrency, + 'gross_amount' => $grossAmount, + 'order_terms' => $this->buildOrderTerms($selectedTermDays, $storeId), + 'buyer_fee_share' => $buyerFeeShare, + ]); + + if (!isset($response['buyer_fee_share'])) { + throw new LocalizedException( + __('Pricing API response missing required field: buyer_fee_share') + ); } - $feeShare = $this->buildBuyerFeeShare($surchargeType, $selectedTermDays, $orderCurrency, $storeId); - $fee = $this->fetchBuyerFee( - $grossAmount, - $selectedTermDays, - $buyerCountry, - $orderCurrency, - $feeShare, - $storeId - ); + $surcharge = (float)$response['buyer_fee_share']; + + // Guard against the API echoing a currency that doesn't match what we + // sent — means our request was reinterpreted and the figure can't be + // applied to the order without FX, which is the API's job not ours. + $respCurrency = isset($response['currency']) ? (string)$response['currency'] : $orderCurrency; + if ($respCurrency !== $orderCurrency) { + throw new LocalizedException( + __( + 'Pricing API returned currency %1 but order currency is %2.', + $respCurrency, + $orderCurrency + ) + ); + } - $this->logRepository->addDebugLog('Surcharge calculated', [ + $this->logRepository->addDebugLog('Surcharge resolved from API', [ 'selected_term' => $selectedTermDays, 'surcharge_type' => $surchargeType, - 'buyer_fee_share' => $feeShare, + 'buyer_fee_share_request' => $buyerFeeShare, + 'buyer_fee_share_response' => $surcharge, 'order_currency' => $orderCurrency, - 'result' => $fee, ]); - return [ - 'amount' => $fee, + $descriptionTemplate = $this->configRepository->getSurchargeLineDescription($storeId); + + return $this->responseCache[$cacheKey] = [ + 'amount' => $surcharge, 'tax_rate' => $this->configRepository->getSurchargeTaxRate($storeId), - 'description' => $this->buildDescription($selectedTermDays, $storeId), + 'description' => (string)__($descriptionTemplate, $selectedTermDays), ]; } /** - * Build the buyer_fee_share payload from merchant config. + * Build the buyer_fee_share block for the pricing request. + * + * Maps merchant config to the API schema: + * - percentage types supply `percentage` + * - fixed types supply `surcharge` (FX-converted to order currency) + * - limit > 0 supplies `cap` (FX-converted to order currency) + * - differential mode supplies `reference_terms` so the API computes + * the threshold itself — no delta math in the plugin + * - `surcharge_basis` is sent explicitly for clarity * - * Fixed and cap amounts are FX-converted from the merchant's configured - * fixed_currency into the order currency before send. Percentage is - * dimensionless. API applies percentage to its own fee base, adds the - * (converted) fixed amount, then caps at the (converted) cap. + * @return array + * @throws LocalizedException when FX rate is missing */ private function buildBuyerFeeShare( string $surchargeType, @@ -144,31 +173,33 @@ private function buildBuyerFeeShare( $hasPercentage = in_array($surchargeType, [SurchargeType::PERCENTAGE, SurchargeType::FIXED_AND_PERCENTAGE]); $hasFixed = in_array($surchargeType, [SurchargeType::FIXED, SurchargeType::FIXED_AND_PERCENTAGE]); - $share = [ - 'surcharge_basis' => 'buyer_pays', + // API default is 100%; send 0 when the merchant hasn't opted into a percentage + // so the fixed-only path doesn't accidentally pass the whole fee on. + $payload = [ 'percentage' => $hasPercentage ? (float)$config['percentage'] : 0.0, - 'surcharge' => $hasFixed - ? $this->convertAmount((float)$config['fixed'], $fixedCurrency, $orderCurrency) - : 0.0, + 'surcharge_basis' => 'buyer_pays', ]; + if ($hasFixed) { + $payload['surcharge'] = $this->convertAmount((float)$config['fixed'], $fixedCurrency, $orderCurrency); + } + if ($config['limit'] !== null) { - $share['cap'] = $this->convertAmount((float)$config['limit'], $fixedCurrency, $orderCurrency); + $payload['cap'] = $this->convertAmount((float)$config['limit'], $fixedCurrency, $orderCurrency); } if ($this->configRepository->isSurchargeDifferential($storeId)) { - $share['reference_terms'] = $this->buildOrderTerms( - $this->configRepository->getDefaultPaymentTerm($storeId), - $storeId - ); + $defaultDays = $this->configRepository->getDefaultPaymentTerm($storeId); + $payload['reference_terms'] = $this->buildOrderTerms($defaultDays, $storeId); } - return $share; + return $payload; } /** - * Build the order_terms object shared between the top-level payload - * and buyer_fee_share.reference_terms. + * Build an order_terms block matching the merchant's payment-terms type. + * + * @return array */ private function buildOrderTerms(int $durationDays, ?int $storeId): array { @@ -182,57 +213,6 @@ private function buildOrderTerms(int $durationDays, ?int $storeId): array return $terms; } - /** - * Call the pricing API and return the authoritative buyer fee. - * - * Results are cached in memory for the current request so multiple - * collectTotals() runs and chip-precompute loops don't redundantly - * hit the API for the same term. - */ - private function fetchBuyerFee( - float $grossAmount, - int $selectedTermDays, - string $buyerCountry, - string $orderCurrency, - array $feeShare, - ?int $storeId - ): float { - $cacheKey = sprintf( - '%s|%d|%s|%s|%d|%s', - $grossAmount, - $selectedTermDays, - $buyerCountry, - $orderCurrency, - (int)$storeId, - md5(json_encode($feeShare) ?: '') - ); - if (isset($this->feeCache[$cacheKey])) { - return $this->feeCache[$cacheKey]; - } - - $response = $this->apiAdapter->execute('/v1/pricing/order/fee', [ - 'buyer_country_code' => $buyerCountry, - 'approved_on_recourse' => false, - 'gross_amount' => $grossAmount, - 'currency' => $orderCurrency, - 'order_terms' => $this->buildOrderTerms($selectedTermDays, $storeId), - 'buyer_fee_share' => $feeShare, - ]); - - $fee = (float)($response['buyer_fee_share'] ?? 0); - $this->feeCache[$cacheKey] = $fee; - return $fee; - } - - private function buildDescription(int $selectedTermDays, ?int $storeId): string - { - return (string)__( - '%1 - %2 days', - $this->configRepository->getSurchargeLineDescription($storeId), - $selectedTermDays - ); - } - /** * Convert an amount between currencies if needed. * @@ -244,10 +224,8 @@ private function convertAmount(float $amount, string $fromCurrency, string $toCu return $amount; } - try { - $currency = $this->currencyFactory->create()->load($fromCurrency); - return (float)$currency->convert($amount, $toCurrency); - } catch (\Exception $e) { + $rate = $this->ratesProvider->getRate($fromCurrency, $toCurrency); + if ($rate === null) { throw new LocalizedException( __( 'Cannot convert surcharge from %1 to %2. ' @@ -257,5 +235,7 @@ private function convertAmount(float $amount, string $fromCurrency, string $toCu ) ); } + + return $amount * $rate; } } diff --git a/Service/Payment/OrderService.php b/Service/Payment/OrderService.php index a8f8efcd..9d93aa11 100755 --- a/Service/Payment/OrderService.php +++ b/Service/Payment/OrderService.php @@ -9,15 +9,19 @@ use Exception; use Magento\Framework\App\RequestInterface; +use Magento\Framework\DB\Transaction; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Url\DecoderInterface; use Magento\Sales\Api\OrderPaymentRepositoryInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Invoice; use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface as TransactionBuilder; use Magento\Sales\Model\Order\Payment\Transaction\Repository as PaymentTransactionRepository; use Magento\Sales\Model\OrderFactory; use Magento\Sales\Model\ResourceModel\Order as OrderResource; +use Magento\Sales\Model\Service\InvoiceService; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; use Two\Gateway\Model\Two; use Two\Gateway\Service\Api\Adapter; @@ -54,6 +58,15 @@ class OrderService */ private $urlCookie; + /** + * @var InvoiceService + */ + private $invoiceService; + + /** + * @var Transaction + */ + private $transaction; /** * @var DecoderInterface */ @@ -62,6 +75,9 @@ class OrderService * @var ConfigRepository */ private $configRepository; + + /** @var BrandRegistryInterface */ + private $brandRegistry; /** * @var RequestInterface */ @@ -94,6 +110,8 @@ class OrderService * @param OrderResource $orderResource * @param OrderFactory $orderFactory * @param UrlCookie $urlCookie + * @param InvoiceService $invoiceService + * @param Transaction $transaction * @param DecoderInterface $urlDecoder * @param ConfigRepository $configRepository * @param RequestInterface $request @@ -109,8 +127,11 @@ public function __construct( OrderResource $orderResource, OrderFactory $orderFactory, UrlCookie $urlCookie, + InvoiceService $invoiceService, + Transaction $transaction, DecoderInterface $urlDecoder, ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, RequestInterface $request, TransactionBuilder $transactionBuilder, PaymentTransactionRepository $paymentTransactionRepository, @@ -123,8 +144,11 @@ public function __construct( $this->orderResource = $orderResource; $this->orderFactory = $orderFactory; $this->urlCookie = $urlCookie; + $this->invoiceService = $invoiceService; + $this->transaction = $transaction; $this->urlDecoder = $urlDecoder; $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->request = $request; $this->transactionBuilder = $transactionBuilder; $this->paymentTransactionRepository = $paymentTransactionRepository; @@ -140,7 +164,7 @@ public function __construct( */ public function getOrderByReference() { - $generalErrorMessage = __('Unable to find the requested %1 order', $this->configRepository::PROVIDER); + $generalErrorMessage = __('Unable to find the requested %1 order', $this->brandRegistry->getProductName()); $this->urlCookie->delete(); if (!$this->getOrderReference()) { throw new LocalizedException($generalErrorMessage); @@ -272,6 +296,14 @@ public function addOrderComment(Order $order, $message) /** * Process Order * + * Promotes the order to Processing and records the authorisation + * transaction. Magento invoice creation is intentionally deferred to the + * fulfilment trigger: SalesOrderShipmentAfter when the trigger is + * `shipment`, SalesOrderSaveAfter when `complete`, or admin-driven + * Magento invoice action when `invoice` (which routes through + * Two::capture()). This keeps the order cancellable until Two has + * actually invoiced the buyer. + * * @param Order $order * @return $this * @throws LocalizedException @@ -281,13 +313,13 @@ public function processOrder(Order $order, string $transactionId) $order->setIsInProcess(true); $order->setState(Order::STATE_PROCESSING); $order->setStatus(Order::STATE_PROCESSING); - $this->orderRepository->save($order); $message = __( '%1 payment has been verified by the customer.', - $this->configRepository::PROVIDER + $this->brandRegistry->getProductName() ); $this->addOrderComment($order, $message); + // addAuthorizationTransaction persists the order and payment. $this->addAuthorizationTransaction($order, $transactionId); return $this; } diff --git a/Setup/Patch/Data/OrderStatuses.php b/Setup/Patch/Data/OrderStatuses.php index 22de6ce7..86e1f777 100755 --- a/Setup/Patch/Data/OrderStatuses.php +++ b/Setup/Patch/Data/OrderStatuses.php @@ -10,6 +10,7 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\Setup\Patch\DataPatchInterface; use Two\Gateway\Model\Two; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -22,6 +23,9 @@ class OrderStatuses implements DataPatchInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var ModuleDataSetupInterface */ @@ -33,9 +37,11 @@ class OrderStatuses implements DataPatchInterface */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, ModuleDataSetupInterface $moduleDataSetup ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->moduleDataSetup = $moduleDataSetup; } @@ -48,8 +54,8 @@ public function apply() $data = []; $statuses = [ - Two::STATUS_NEW => sprintf('%s New Order', $this->configRepository::PROVIDER), - Two::STATUS_FAILED => sprintf('%s Failed', $this->configRepository::PROVIDER), + Two::STATUS_NEW => sprintf('%s New Order', $this->brandRegistry->getProvider()), + Two::STATUS_FAILED => sprintf('%s Failed', $this->brandRegistry->getProvider()), ]; foreach ($statuses as $code => $info) { diff --git a/Setup/Patch/Data/PendingPaymentStatus.php b/Setup/Patch/Data/PendingPaymentStatus.php index 06161459..a128a511 100644 --- a/Setup/Patch/Data/PendingPaymentStatus.php +++ b/Setup/Patch/Data/PendingPaymentStatus.php @@ -10,6 +10,7 @@ use Magento\Framework\Setup\ModuleDataSetupInterface; use Magento\Framework\Setup\Patch\DataPatchInterface; use Two\Gateway\Model\Two; +use Two\Gateway\Api\BrandRegistryInterface; use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository; /** @@ -22,6 +23,9 @@ class PendingPaymentStatus implements DataPatchInterface */ private $configRepository; + /** @var BrandRegistryInterface */ + private $brandRegistry; + /** * @var ModuleDataSetupInterface */ @@ -33,9 +37,11 @@ class PendingPaymentStatus implements DataPatchInterface */ public function __construct( ConfigRepository $configRepository, + BrandRegistryInterface $brandRegistry, ModuleDataSetupInterface $moduleDataSetup ) { $this->configRepository = $configRepository; + $this->brandRegistry = $brandRegistry; $this->moduleDataSetup = $moduleDataSetup; } @@ -53,7 +59,7 @@ public function apply() $this->moduleDataSetup->getConnection()->insert( $this->moduleDataSetup->getTable('sales_order_status'), - ['status' => Two::STATUS_PENDING, 'label' => sprintf('%s Pending', $this->configRepository::PROVIDER)] + ['status' => Two::STATUS_PENDING, 'label' => sprintf('%s Pending', $this->brandRegistry->getProvider())] ); $this->moduleDataSetup->getConnection()->insert( diff --git a/Test/Unit/Model/Config/Backend/SurchargeGridTest.php b/Test/Unit/Model/Config/Backend/SurchargeGridTest.php index d4206895..321eb007 100644 --- a/Test/Unit/Model/Config/Backend/SurchargeGridTest.php +++ b/Test/Unit/Model/Config/Backend/SurchargeGridTest.php @@ -234,7 +234,9 @@ public function callAfterSave(): void $inheritData = $this->groups['payment_terms']['fields']['surcharge_grid']['inherit']; } - $maxFixed = ConfigRepository::SURCHARGE_FIXED_MAX; + // Hard-coded to the test brand's surcharge bound — see + // BrandRegistryInterface::getSurchargeFixedMax(). + $maxFixed = 25; $maxPercentage = ConfigRepository::SURCHARGE_PERCENTAGE_MAX; foreach ($this->value as $days => $fields) { diff --git a/Test/Unit/Service/Api/AdapterTest.php b/Test/Unit/Service/Api/AdapterTest.php index 3e922f36..1617936d 100644 --- a/Test/Unit/Service/Api/AdapterTest.php +++ b/Test/Unit/Service/Api/AdapterTest.php @@ -90,7 +90,7 @@ public function testSuccessEmptyBodyTokenEndpointReturnsHeaders(): void $this->assertEquals('abc123', $result['x-delegation-token']); } - // ── Non-2xx responses (ABN-287 critical) ──────────────────────────── + // ── Non-2xx responses ─────────────────────────────────────────────── public function testNon2xxWithBodyReturnsJsonPlusHttpStatus(): void { diff --git a/composer.json b/composer.json old mode 100644 new mode 100755 index be430375..e8e8d9c4 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "two-inc/magento2", - "description": "Two B2B BNPL payments extension", + "description": "Two B2B BNPL payments extension for Magento", "type": "magento2-module", - "version": "1.16.2", + "version": "2.0.0-dev", "license": [ "OSL-3.0", "AFL-3.0" diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml old mode 100755 new mode 100644 index ae87e816..fc9fe298 --- a/etc/adminhtml/di.xml +++ b/etc/adminhtml/di.xml @@ -9,4 +9,8 @@ + + + diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml index e7e5fedd..f99ebea2 100755 --- a/etc/adminhtml/routes.xml +++ b/etc/adminhtml/routes.xml @@ -1,8 +1,10 @@ +/** + * Copyright © Two.inc All rights reserved. + * See COPYING.txt for license details. + */ +--> diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 418bb483..22145389 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -9,7 +9,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> - +
@@ -17,11 +17,6 @@ two_gateway Magento_Sales::config_sales - - - Two\Gateway\Block\Adminhtml\System\Config\Field\Header - @@ -42,7 +37,7 @@ - + API key for sandbox environment is available on your merchant portal (however please reach out to integration@two.inc for access to production keys). Magento\Config\Model\Config\Backend\Encrypted required-entry @@ -57,7 +52,7 @@ - + The debug mode enables writing to the error logs below. Debug mode should only be enabled when the sandbox enviroment is active Magento\Config\Model\Config\Source\Yesno payment/two_payment/debug @@ -93,7 +88,7 @@ - Descriptive title which gives the buyer a better understanding of this payment method e.g. Business invoice - 30 days + Descriptive title which gives the buyer a better understanding of this payment method required-entry 1 @@ -146,7 +141,7 @@ - Select the payment term(s) you want to offer. If a custom duration is set below, this selection is optional. + Select the payment term(s) you want to offer. Two\Gateway\Block\Adminhtml\System\Config\Field\PaymentTermsCheckboxes Two\Gateway\Model\Config\Backend\PaymentTermsCheckboxes payment/two_payment/payment_terms @@ -174,7 +169,7 @@ - + Select a method to surcharge your customer. Two\Gateway\Model\Config\Source\SurchargeType payment/two_payment/surcharge_type @@ -188,9 +183,10 @@ - Description shown to the buyer for the surcharge line item. + %1 to insert the selected number of days (e.g. "Payment terms fee - %1 days"). If you leave the default, the word days is translated per locale.]]> payment/two_payment/surcharge_line_description + @@ -201,7 +197,7 @@ - + Two\Gateway\Block\Adminhtml\System\Config\Field\SurchargeGrid Two\Gateway\Model\Config\Backend\SurchargeGrid diff --git a/etc/config.xml b/etc/config.xml index 66ede07f..9f9e4e3f 100755 --- a/etc/config.xml +++ b/etc/config.xml @@ -3,6 +3,10 @@ /** * Copyright © Two.inc All rights reserved. * See COPYING.txt for license details. + * + * Canonical Two_Gateway defaults. Overlay packages may merge + * per-brand config additively via their own etc/config.xml; values + * declared by overlays loaded after Two_Gateway win. */ --> 1 - 1.16.2 - Business invoice - 30 days + 1.14.1 + Two + -10 sandbox FUNDED_INVOICE 14 @@ -30,11 +35,11 @@ 0 standard - 30 + 14,30,60,90 30 none 0 - Payment terms fee + Payment terms fee - %1 days 0 1 diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 5b7b7db6..6d0951e9 100755 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -7,7 +7,53 @@ --> - - + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + +
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json new file mode 100644 index 00000000..ab62b3cf --- /dev/null +++ b/etc/db_schema_whitelist.json @@ -0,0 +1,64 @@ +{ + "sales_order": { + "column": { + "two_order_reference": true, + "two_order_id": true, + "two_surcharge_amount": true, + "base_two_surcharge_amount": true, + "two_surcharge_tax_amount": true, + "base_two_surcharge_tax_amount": true, + "two_surcharge_description": true, + "two_surcharge_tax_rate": true, + "two_surcharge_invoiced": true, + "base_two_surcharge_invoiced": true, + "two_surcharge_refunded": true, + "base_two_surcharge_refunded": true + } + }, + "quote": { + "column": { + "two_surcharge_amount": true, + "base_two_surcharge_amount": true, + "two_surcharge_tax_amount": true, + "base_two_surcharge_tax_amount": true, + "two_surcharge_description": true, + "two_surcharge_tax_rate": true + } + }, + "quote_address": { + "column": { + "two_surcharge_amount": true, + "base_two_surcharge_amount": true, + "two_surcharge_tax_amount": true, + "base_two_surcharge_tax_amount": true, + "two_surcharge_description": true, + "two_surcharge_tax_rate": true + } + }, + "sales_invoice": { + "column": { + "two_surcharge_amount": true, + "base_two_surcharge_amount": true, + "two_surcharge_tax_amount": true, + "base_two_surcharge_tax_amount": true, + "two_surcharge_description": true, + "two_surcharge_tax_rate": true + } + }, + "sales_creditmemo": { + "column": { + "two_surcharge_amount": true, + "base_two_surcharge_amount": true, + "two_surcharge_tax_amount": true, + "base_two_surcharge_tax_amount": true, + "two_surcharge_description": true, + "two_surcharge_tax_rate": true + } + }, + "sales_order_grid": { + "column": { + "two_surcharge_amount": true, + "base_two_surcharge_amount": true + } + } +} diff --git a/etc/di.xml b/etc/di.xml index bca13e56..91a73767 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -15,6 +15,13 @@ type="Two\Gateway\Model\Webapi\SoleTrader"/> + + + + @@ -41,6 +48,44 @@ + + + + + + + + + + + + + sales_order.two_surcharge_amount + sales_order.base_two_surcharge_amount + + + + + + + + + + Two\Gateway\Model\Pdf\Total\Surcharge + + + + + Two\Gateway\Model\Pdf\Total\Surcharge + + + + + + diff --git a/etc/events.xml b/etc/events.xml old mode 100755 new mode 100644 index 86723f9b..53a5c1b6 --- a/etc/events.xml +++ b/etc/events.xml @@ -12,10 +12,22 @@ + + + + + + + + + + + +
diff --git a/etc/extension_attributes.xml b/etc/extension_attributes.xml index a0382bc3..955fd876 100755 --- a/etc/extension_attributes.xml +++ b/etc/extension_attributes.xml @@ -18,4 +18,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/fieldset.xml b/etc/fieldset.xml new file mode 100644 index 00000000..fba9c129 --- /dev/null +++ b/etc/fieldset.xml @@ -0,0 +1,36 @@ + + + + +
+ + + + + + +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+
diff --git a/etc/sales.xml b/etc/sales.xml index cda24018..1933e7e7 100644 --- a/etc/sales.xml +++ b/etc/sales.xml @@ -12,4 +12,14 @@
+
+ + + +
+
+ + + +
diff --git a/etc/webapi.xml b/etc/webapi.xml index d4bdb62f..b4d5ccdc 100644 --- a/etc/webapi.xml +++ b/etc/webapi.xml @@ -12,5 +12,11 @@ + + + + + + diff --git a/frpc.toml b/frpc.toml deleted file mode 100644 index 57fd4fff..00000000 --- a/frpc.toml +++ /dev/null @@ -1,13 +0,0 @@ -serverAddr = "frp.beta.two.inc" -serverPort = 7000 -auth.token = "{{ .Envs.FRP_AUTH_TOKEN }}" - -transport.tls.enable = true -transport.poolCount = 10 - -[[proxies]] -localPort = {{ .Envs.PORT }} -name = "{{ .Envs.SUBDOMAIN }}" -type = "http" -localIP = "{{ .Envs.HOST }}" -subdomain = "{{ .Envs.SUBDOMAIN }}" diff --git a/i18n/nl_NL.csv b/i18n/nl_NL.csv index 74d943dd..06be7cb7 100644 --- a/i18n/nl_NL.csv +++ b/i18n/nl_NL.csv @@ -4,7 +4,7 @@ "%1 order has been marked as cancelled","%1 bestelling is gemarkeerd als geannuleerd" "%1 order invoice has not been issued yet.","%1 bestelfactuur is nog niet verzonden." "%1 order marked as completed.","%1 bestelling gemarkeerd als voltooid." -"%1 order marked as partially completed.","%1 bestilling merket som delvis fullført." +"%1 order marked as partially completed.","%1 bestelling gemarkeerd als gedeeltelijk voltooid." "%1 payment has been verified by the customer.","%1 betaling is door de klant geverifieerd." "%1 requires whole order to be shipped before it can be fulfilled.","%1 vereist dat de gehele bestelling wordt verzonden voordat deze kan worden uitgevoerd." "%1 terms and conditions","%1 voorwaarden en condities" diff --git a/i18n/sv_SE.csv b/i18n/sv_SE.csv index 5ccb1f25..56df6a57 100644 --- a/i18n/sv_SE.csv +++ b/i18n/sv_SE.csv @@ -33,7 +33,7 @@ Branding,Branding "Click here to log in or sign up as a Sole Trader.","Klicka här för att logga in eller registrera dig som enskild näringsidkare." "Company ID",Organisationsnummer "Company Name",Företagsnamn -"Could not initiate capture with %1","Kunde inte initiera infångning med %1" +"Could not initiate capture with %1","Kunde inte initiera debitering med %1" "Could not initiate refund with %1","Kunde inte initiera återbetalning med %1" "Could not update %1 order status to cancelled. Please contact support with order ID %2. Error: %3","Kunde inte uppdatera %1 orderstatus till annullerad. Kontakta supporten med beställnings-ID %2. Fel: %3" "Country",Land diff --git a/start-proxy.sh b/start-proxy.sh deleted file mode 100755 index 7e8ec5ed..00000000 --- a/start-proxy.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/bin/bash - -# Usage: ./start-proxy.sh [--background|stop|url] [FRP_AUTH_TOKEN] -# -# If no token is provided, attempts to fetch it from GCP Secret Manager -# (requires an active @two.inc gcloud login). - -# Source .env.local if it exists -if [ -f .env.local ]; then - set -a - source .env.local - set +a -fi - -# Define environment variables -PROXY_USER="${PROXY_USER:-$USER}" -export HOST="${HOST:-127.0.0.1}" -export PORT="${PORT:-1234}" -PIDFILE=".frpc.pid" - -# Sanitize PROXY_USER for subdomain use: lowercase, replace invalid chars with hyphens, clean up hyphens -USER_LOWER=$(echo "${PROXY_USER}" | tr '[:upper:]' '[:lower:]') -SANITIZED_USER=$(echo "${USER_LOWER}" | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/^-+|-+$//g' | sed -E 's/--+/-/g') -export SUBDOMAIN="magento-${SANITIZED_USER}" - -PROXY_URL="https://${SUBDOMAIN}.frp.beta.two.inc" - -# ── stop mode ──────────────────────────────────────────────────────────────── -if [ "$1" = "stop" ]; then - if [ -f "$PIDFILE" ]; then - kill "$(cat "$PIDFILE")" 2>/dev/null - rm -f "$PIDFILE" - echo "Proxy stopped" - fi - exit 0 -fi - -# ── url mode (just print the proxy URL if running) ─────────────────────────── -if [ "$1" = "url" ]; then - if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - echo "$PROXY_URL" - fi - exit 0 -fi - -# ── start mode ─────────────────────────────────────────────────────────────── - -# Parse arguments: first positional may be --background, token is the remaining arg -MODE="" -TOKEN_ARG="" -for arg in "$@"; do - if [ "$arg" = "--background" ]; then - MODE="background" - else - TOKEN_ARG="$arg" - fi -done - -# Kill any existing frpc from a previous run -if [ -f "$PIDFILE" ]; then - kill "$(cat "$PIDFILE")" 2>/dev/null - rm -f "$PIDFILE" -fi - -# Resolve FRP auth token -if [ -n "$TOKEN_ARG" ]; then - export FRP_AUTH_TOKEN="$TOKEN_ARG" -elif [ -n "$FRP_AUTH_TOKEN" ]; then - export FRP_AUTH_TOKEN -else - echo "Fetching FRP_AUTH_TOKEN from Secret Manager..." - if ! FRP_AUTH_TOKEN=$(gcloud secrets versions access latest --secret="FRP_AUTH_TOKEN" --project="two-beta" 2>&1); then - echo "Failed to fetch FRP_AUTH_TOKEN:" - echo "$FRP_AUTH_TOKEN" - echo "" - echo "Usage: ./start-proxy.sh [--background] " - echo " or: export FRP_AUTH_TOKEN= before running" - exit 1 - fi - export FRP_AUTH_TOKEN -fi - -# Start frpc in background -frpc -c frpc.toml & -FRP_PID=$! - -sleep 2 - -if ! ps -p $FRP_PID >/dev/null 2>&1; then - echo "frpc failed to start" - exit 1 -fi - -echo "$FRP_PID" > "$PIDFILE" - -echo "" -echo "Proxy: $PROXY_URL" -echo "" - -# If --background, detach and return -if [ "$MODE" = "background" ]; then - disown $FRP_PID - exit 0 -fi - -# Foreground mode: wait until interrupted -trap 'kill $FRP_PID 2>/dev/null; rm -f "$PIDFILE"' EXIT -wait $FRP_PID diff --git a/view/adminhtml/layout/sales_order_creditmemo_new.xml b/view/adminhtml/layout/sales_order_creditmemo_new.xml new file mode 100644 index 00000000..4d5ec59f --- /dev/null +++ b/view/adminhtml/layout/sales_order_creditmemo_new.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/view/adminhtml/layout/sales_order_creditmemo_updateqty.xml b/view/adminhtml/layout/sales_order_creditmemo_updateqty.xml new file mode 100644 index 00000000..551e9c6e --- /dev/null +++ b/view/adminhtml/layout/sales_order_creditmemo_updateqty.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/view/adminhtml/layout/sales_order_creditmemo_view.xml b/view/adminhtml/layout/sales_order_creditmemo_view.xml new file mode 100644 index 00000000..65ea5953 --- /dev/null +++ b/view/adminhtml/layout/sales_order_creditmemo_view.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/sales_order_invoice_new.xml b/view/adminhtml/layout/sales_order_invoice_new.xml new file mode 100644 index 00000000..6a250758 --- /dev/null +++ b/view/adminhtml/layout/sales_order_invoice_new.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/sales_order_invoice_view.xml b/view/adminhtml/layout/sales_order_invoice_view.xml new file mode 100644 index 00000000..6a250758 --- /dev/null +++ b/view/adminhtml/layout/sales_order_invoice_view.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/view/adminhtml/layout/sales_order_view.xml b/view/adminhtml/layout/sales_order_view.xml index 2e9389b8..2fe8255c 100755 --- a/view/adminhtml/layout/sales_order_view.xml +++ b/view/adminhtml/layout/sales_order_view.xml @@ -13,5 +13,8 @@ name="two_custom_view" template="Two_Gateway::order/view/view.phtml"/> + + + diff --git a/view/adminhtml/requirejs-config.js b/view/adminhtml/requirejs-config.js index 63197b28..b68f86c0 100755 --- a/view/adminhtml/requirejs-config.js +++ b/view/adminhtml/requirejs-config.js @@ -1,3 +1,6 @@ var config = { - deps: ['Two_Gateway/js/button-functions', 'Two_Gateway/js/payment-terms-config'] + deps: [ + 'Two_Gateway/js/button-functions', + 'Two_Gateway/js/payment-terms-config' + ] }; diff --git a/view/adminhtml/templates/order/view/view.phtml b/view/adminhtml/templates/order/view/view.phtml index e3955100..5f3f0c22 100755 --- a/view/adminhtml/templates/order/view/view.phtml +++ b/view/adminhtml/templates/order/view/view.phtml @@ -6,10 +6,10 @@ ?> getMethod() == \Two\Gateway\Model\Two::CODE): ?> - configRepository::PROVIDER; ?> + getProductName(); ?>