From 399571b898990845b67ff5b86f5251a7d5a833aa Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 7 May 2026 14:54:22 +0100 Subject: [PATCH 01/14] INF-1159/ci: shared release-notes script + auto-PR preview body Mirrors the abn fork's PR #69. New .github/scripts/release-notes.py is the single source of truth for the bucketing + bump-level decision. release.yml calls it for the actual GitHub Release notes; auto-pr.yml calls it with origin/main..HEAD and uses the output as the rolling sync PR's body. The PR description refreshes on every push to develop with the bucketed list AND the predicted bump level. Why Python: regex grows readable (named groups, real re.match) and the script is testable as it grows. setup-python is already installed so the cost is marginal. --- .github/scripts/release-notes.py | 130 +++++++++++++++++++++++++++++++ .github/workflows/auto-pr.yml | 68 +++++++++++----- .github/workflows/release.yml | 60 ++------------ 3 files changed, 184 insertions(+), 74 deletions(-) create mode 100755 .github/scripts/release-notes.py diff --git a/.github/scripts/release-notes.py b/.github/scripts/release-notes.py new file mode 100755 index 00000000..d0b4dcbf --- /dev/null +++ b/.github/scripts/release-notes.py @@ -0,0 +1,130 @@ +#!/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 f841bdf7..6bf90351 100644 --- a/.github/workflows/auto-pr.yml +++ b/.github/workflows/auto-pr.yml @@ -15,6 +15,11 @@ 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 @@ -26,34 +31,55 @@ jobs: client-id: ${{ vars.TWO_INC_APP_CLIENT_ID }} private-key: ${{ secrets.TWO_INC_APP_PRIVATE_KEY }} - - name: Ensure open PR develop → main + - name: Build preview of next release notes + id: preview 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 - 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 - )" + + # 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91eadf87..f5f067fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,15 +64,11 @@ 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 - # 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. + # 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. if: steps.gate.outputs.skip == '0' id: bump run: | @@ -84,52 +80,10 @@ jobs: range="HEAD" fi - : > 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 + level=$(.github/scripts/release-notes.py "$range" release-notes.md) echo "level=$level" >> "$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 "Selected bump level: $level (range: $range)" echo "--- release-notes.md preview ---" cat release-notes.md From 5ccaf2ab7a7678a6e6ab190ec0c95a9bd0c3a4e6 Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 7 May 2026 15:03:25 +0100 Subject: [PATCH 02/14] INF-1159/ci: gate release.yml on green CI via workflow_run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the abn fork's PR. Currently release.yml fires on every push to main, including broken commits — a sync PR somehow merged with red CI would still produce a tagged Release page. Switching the trigger from `push: [main]` to a workflow_run listener on the CI workflow's completion, with: 1. CI's conclusion must be 'success' on the same SHA. 2. Skip on the bumpver loop commit (same guard as before, sourced from workflow_run.head_commit instead of push event). 3. Checkout pinned to workflow_run.head_sha (covers the case where another commit landed between CI passing and release firing). workflow_run resolves the workflow file from the default branch, so this change has to land on main before gating kicks in. The first sync-PR merge after this lands will use the OLD shape; the next one and all after will go through workflow_run. --- .github/workflows/release.yml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91eadf87..f5bda12c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,13 @@ 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: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] permissions: @@ -14,9 +20,18 @@ concurrency: jobs: release: runs-on: ${{ vars.RUNNER_STANDARD }} - # 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')" + # 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') steps: - name: Mint GitHub App token id: app-token @@ -27,7 +42,10 @@ jobs: - uses: actions/checkout@v5 with: - ref: main + # Pin to the SHA that just passed CI, not main's tip, + # in case another commit landed between CI completion and + # this workflow firing. + ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} From 852994648a699cb4c1754148f56ef899660d1f22 Mon Sep 17 00:00:00 2001 From: Bharat Date: Thu, 7 May 2026 15:04:57 +0100 Subject: [PATCH 03/14] INF-1159/ci: gate release.yml on green CI + document release flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently release.yml fires on every push to main regardless of CI status — a broken commit landing on main (e.g. via a sync PR somehow merged with red CI) would still produce a tagged Release page. Switching the trigger from `push: [main]` to a workflow_run listener on the CI workflow's completion. Two gates: 1. CI's conclusion must be 'success' on the same SHA. workflow_run fires on every CI completion (success/failure/cancelled); the if-filter narrows to green. 2. Skip on the bumpver loop commit (same guard as before, sourced from workflow_run.head_commit.message). Checkout pinned to workflow_run.head_sha (covers the rare case where another commit landed between CI passing and release firing). Note: workflow_run resolves the workflow file from the default branch, so the gating only kicks in after this lands on main. The first sync-PR merge after this lands will use the OLD shape; the next and all after go through workflow_run. README: new Releases section explains the tag/auto-release flow end-to-end — bumpver level rules, auto-PR rolling sync PR with release-notes preview, and the merge-back loop. --- .github/workflows/release.yml | 37 ++++++++++++++++++++++++----------- README.md | 22 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5bda12c..1a3a334d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,28 +42,43 @@ jobs: - uses: actions/checkout@v5 with: - # Pin to the SHA that just passed CI, not main's tip, - # in case another commit landed between CI completion and - # this workflow firing. - ref: ${{ github.event.workflow_run.head_sha }} + # 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: Skip if HEAD already tagged + - 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. id: gate + env: + PASSED_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail - # Match bare numeric tags (1.14.1 etc.) — the repo's - # established convention. Reject the legacy `abn-*` prefix - # so a stale fork tag pointing at HEAD doesn't suppress a - # legitimate release. + 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 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" - else - echo "skip=0" >> "$GITHUB_OUTPUT" + exit 0 fi + echo "skip=0" >> "$GITHUB_OUTPUT" - name: Set up Python if: steps.gate.outputs.skip == '0' diff --git a/README.md b/README.md index 8d9f2f7b..c5d11fe7 100755 --- a/README.md +++ b/README.md @@ -144,6 +144,28 @@ make test-e2e TWO_API_KEY= | `make format` | Run Prettier on frontend JS/CSS/templates | | `make clean` | Stop and remove the Magento container | +## Releases + +Releases are cut automatically once CI passes on `main`. + +### Tagging (automatic, gated on CI) + +`.github/workflows/release.yml` is triggered by the `CI` workflow completing on `main`. When CI's conclusion is `success`, it: + +1. Skips itself if the head commit is already a `chore: Bump version` commit, or if the SHA already carries a numeric tag. +2. Reads conventional-commit types in `..HEAD` to pick the bump level: + - `BREAKING CHANGE:` / `!:` → **major** + - `feat:` → **minor** + - everything else → **patch** + + Linear ticket prefixes are supported (e.g. `INF-123/feat:`). +3. Runs `bumpver update -- --no-tag-commit --no-push` to rewrite `composer.json`, `etc/config.xml`, and `bumpver.toml`. +4. Tags `X.Y.Z` (bare numeric, matching the established tag convention), pushes the bump commit and tag under the org GitHub App identity, and creates a GitHub Release with a bucketed changelog (Breaking / Features / Fixes / Internals / Other) — so reading the Release page reveals at a glance why the bump was a major / minor / patch. + +`.github/workflows/merge-back.yml` keeps `develop` fast-forwarded to match `main` after each release. `.github/workflows/auto-pr.yml` keeps a rolling sync PR open from `develop` to `main` with a preview of the next release notes — the same bucketing the actual Release page uses. + +To trigger a release, merge the rolling sync PR into `main`. CI runs on the merged commit; once green, `release.yml` fires. + ## Links - [Two developer documentation](https://docs.two.inc/) From abdca90cb77b49dc9fbc8f16a24c964aa4e4fcaa Mon Sep 17 00:00:00 2001 From: Douglas Lindsay Date: Tue, 12 May 2026 23:22:59 +0100 Subject: [PATCH 04/14] 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(); ?>