Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
399571b
INF-1159/ci: shared release-notes script + auto-PR preview body
brtkwr May 7, 2026
47f5fd4
Merge pull request #104 from two-inc/ci/shared-release-notes
brtkwr May 7, 2026
5ccaf2a
INF-1159/ci: gate release.yml on green CI via workflow_run
brtkwr May 7, 2026
8529946
INF-1159/ci: gate release.yml on green CI + document release flow
brtkwr May 7, 2026
c36e921
Merge pull request #106 from two-inc/ci/gate-release-on-ci
brtkwr May 7, 2026
abdca90
TWO-24485: rewrite plugin as brand-aware Two_Gateway module
dgjlindsay May 12, 2026
8c88daa
dev: hide admin data-grid loading mask via local module
dgjlindsay May 13, 2026
d47d623
TWO-24485/fix: fire chip surcharge fetch on init when totals already …
dgjlindsay May 13, 2026
205a514
TWO-24485/test: fix Repository constructor signature in unit tests
dgjlindsay May 14, 2026
86862c1
TWO-24485/test: fix remaining unit test fixtures after brand rewrite
dgjlindsay May 14, 2026
654403a
TWO-24485/test: align calculator + adapter tests with runtime behaviour
dgjlindsay May 14, 2026
c8b604c
TWO-24485/ci: install plugin from uploaded source, not Packagist
dgjlindsay May 14, 2026
58ba972
TWO-24485: address PR review threads — restore CI gate, drop dead source
dgjlindsay May 14, 2026
b5dd547
TWO-24485: adversarial review fixes — i18n, brand seam, CI source
dgjlindsay May 14, 2026
949f0de
TWO-24485/ci: ship tracked files via git ls-files, not git archive
dgjlindsay May 14, 2026
9ea66ba
TWO-24485: iter-2 tidy — wrap fallback labels + tighten CSP comment
dgjlindsay May 14, 2026
cfc1710
Merge pull request #109 from two-inc/doug/TWO-24485-vanilla-split
dgjlindsay May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,23 @@ jobs:
docker run --detach --name magento-project-community-edition \
michielgerritsen/magento-project-community-edition:${{ matrix.php_image }}-magento${{ matrix.magento }}

- name: Create branch for Composer (composer rejects published versions on a feature branch)
- name: Upload tracked source into container
# Ship only tracked files — avoids leaking .git/, dotfiles,
# or any untracked working-tree state into the CI container.
# `git ls-files | tar` is preferred over `git archive` here
# because the latter honours .gitattributes export-ignore
# and would strip phpstan.neon / phpunit.xml / Test/ which CI
# needs but the release zip does not.
run: |
git checkout -b continuous-integration-test-branch
sed -i '/version/d' ./composer.json
docker exec magento-project-community-edition mkdir -p /data/extensions/magento-plugin
git ls-files -z | tar --null -cf - -T - | docker exec -i magento-project-community-edition tar -x -C /data/extensions/magento-plugin

- name: Upload code into container
run: docker cp "$(pwd)" magento-project-community-edition:/data/extensions/

- name: Install extension
- name: Install extension from uploaded source
run: |
docker exec magento-project-community-edition \
composer require two-inc/magento2:@dev --no-plugins
composer config repositories.local path '/data/extensions/*'
docker exec magento-project-community-edition \
composer require 'two-inc/magento2:*@dev' --no-plugins

- name: Activate extension
run: |
Expand Down Expand Up @@ -169,13 +174,23 @@ jobs:
docker run --detach --name magento-project-community-edition \
michielgerritsen/magento-project-community-edition:${{ matrix.php_image }}-magento${{ matrix.magento }}

- name: Upload code into container
run: docker cp "$(pwd)" magento-project-community-edition:/data/extensions/
- name: Upload tracked source into container
# Ship only tracked files — avoids leaking .git/, dotfiles,
# or any untracked working-tree state into the CI container.
# `git ls-files | tar` is preferred over `git archive` here
# because the latter honours .gitattributes export-ignore
# and would strip phpstan.neon / phpunit.xml / Test/ which CI
# needs but the release zip does not.
run: |
docker exec magento-project-community-edition mkdir -p /data/extensions/magento-plugin
git ls-files -z | tar --null -cf - -T - | docker exec -i magento-project-community-edition tar -x -C /data/extensions/magento-plugin

- name: Install extension
- name: Install extension from uploaded source
run: |
docker exec magento-project-community-edition \
composer require two-inc/magento2:@dev --no-plugins
composer config repositories.local path '/data/extensions/*'
docker exec magento-project-community-edition \
composer require 'two-inc/magento2:*@dev' --no-plugins

- name: Run setup:di:compile
run: |
Expand Down
57 changes: 45 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -27,25 +42,43 @@ 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: 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'
Expand Down Expand Up @@ -156,7 +189,7 @@ jobs:
- name: Append full-diff link to release notes
# Now that the new version is known we can render a proper
# <prev>..<new> link instead of leaving the right-hand side
# blank (the prior shape rendered as "abn-1.13.2..").
# blank (the prior shape rendered as "<prefix>-1.13.2..").
if: steps.gate.outputs.skip == '0'
env:
PREV: ${{ steps.bump.outputs.prev }}
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ composer.lock
# Python
*.venv
.phpunit.result.cache
.fork-compare/
.serena/
agent-notes/
upstream-backport-brief.md
plans/
122 changes: 9 additions & 113 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <command>"
```

### 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.
81 changes: 81 additions & 0 deletions Api/BrandRegistryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* Copyright © Two.inc All rights reserved.
* See COPYING.txt for license details.
*/
declare(strict_types=1);

namespace Two\Gateway\Api;

/**
* Per-brand identity values that vary between distributable packages
* of the Two payment gateway. The default binding lives in this
* package; downstream brand-overlay packages may rebind this
* interface to their own implementation via DI preference.
*
* Callers must depend on this interface, not on the concrete impls.
*/
interface BrandRegistryInterface
{
/**
* Short brand name (e.g. "Two"). Used in admin surfaces and a
* handful of merchant-facing strings.
*/
public function getProvider(): string;

/**
* Legal entity name. Used in T&Cs and similar formal contexts.
*/
public function getProviderFullName(): string;

/**
* Customer-facing product label (e.g. "Two"). Preferred over
* getProvider() for buyer-visible strings.
*/
public function getProductName(): string;

/**
* sprintf template for the brand's checkout-page host. Receives
* the env tag (e.g. "sandbox", "staging") as %s and yields the
* full host the buyer is redirected to for invoice signing.
*/
public function getCheckoutUrlTemplate(): string;

/**
* Buyer-selectable payment terms (in days) supported by this
* brand's commercial agreement.
*
* @return int[]
*/
public function getAvailablePaymentTerms(): array;

/**
* Maximum allowed value of a fixed-amount surcharge configured
* by the merchant, expressed in a specific currency. Returning
* null means there is no upper bound — any positive value is
* acceptable. Calling code must interpret null as "no max" and
* skip the upper-bound check.
*
* @return array{amount: float, currency: string}|null
*/
public function getSurchargeFixedMax(): ?array;

/**
* Short brand tag used to decorate non-production checkout URLs
* (e.g. `?brand=<tag>`). 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;

/**
* Merchant sign-up URL shown on the admin config header block.
*/
public function getSignUpUrl(): string;

/**
* Plugin documentation URL shown on the admin config header block.
*/
public function getDocumentationUrl(): string;
}
Loading
Loading