Skip to content

Added multi-website gift card associations, customer "Check Balance" page, and rate-limited lookups - #1062

Open
mageaustralia wants to merge 24 commits into
MahoCommerce:mainfrom
mageaustralia:feat/giftcard-multi-website-and-customer-balance
Open

Added multi-website gift card associations, customer "Check Balance" page, and rate-limited lookups#1062
mageaustralia wants to merge 24 commits into
MahoCommerce:mainfrom
mageaustralia:feat/giftcard-multi-website-and-customer-balance

Conversation

@mageaustralia

Copy link
Copy Markdown
Contributor

What's in this PR

Multi-website associations (4 commits)

A single website_id scalar on giftcard tied each card to exactly one website, which doesn't reflect how multi-website stores actually use gift cards (head office issues a card customers spend across any of the brand's storefronts). This adds a giftcard_website junction so a card can be valid on an arbitrary subset of websites:

  • New giftcard_website table (PK giftcard_id, website_id, indexed by website_id, FKs to giftcard and core_website both ON DELETE CASCADE)
  • Maho_Giftcard_Model_Giftcard::getWebsiteIds() / setWebsiteIds() backed by the junction; isValidForWebsite() now does a membership check (in_array against the set) and fails closed on no associations
  • Resource _afterSave syncs the junction in a transaction; an explicit empty set throws Mage_Core_Exception (A gift card must be associated with at least one website.) rather than silently orphaning the card
  • Admin form converts the existing single-website select into a multiselect (editable on existing cards too)
  • Admin grid LEFT JOINs the junction and shows comma-separated website names, filterable by membership (HAVING FIND_IN_SET(N, website_ids))
  • Legacy giftcard.website_id scalar kept nullable for back-compat (currency derivation, FK to core_website); flagged for removal in a future major

Schema migration (1.0.0 → 1.1.0)

sql/giftcard_setup/upgrade-1.0.0-1.1.0.php backfills the junction permissively: each existing card is associated with every active website (website_id > 0). Preserves the most common pre-1.1.0 behaviour where a single-website assignment was administrative but apply-time validation was effectively store-agnostic. Uses INSERT ... SELECT ... LEFT JOIN ... WHERE ... IS NULL (portable across MySQL/PostgreSQL/SQLite, naturally idempotent on re-run). Empty source table on fresh installs short-circuits to zero inserts.

Admin edit-page transaction-history tab

Admins tracing where a card's balance went currently have to leave the card to scan the global Sales → Gift Card History page, then filter or remember the giftcard_id. New "Transaction History" tab next to the General form, wired via the canonical Mage_Adminhtml_Block_Cms_Page_Edit layout-XML pattern (<reference name="left"> with <action method="addTab">). Card-scoped grid, read-only, AJAX paging routed through historyGridAction on the existing admin controller. Tab is hidden on the create form.

Customer "Check Gift Card Balance" page

New page under My Account where a logged-in customer can look up the remaining balance + expiry on a gift card by code. Form lives in base/default (pure stock theme classes: .box-account, .form-list, .buttons-set, etc.). One-shot session storage of the lookup result so back/forward navigation doesn't re-display the previous customer's check on a shared device. Errors stay opaque — "not found", "expired", "disabled", "no website membership" all collapse to the same "We could not find an active gift card for that code on this store." so the endpoint can't be used to enumerate code existence.

Rate limiting: per-customer via the shared Mage\Core\Helper\Data::rateLimiterBy() factory (scope key = customer_id), 10 failed lookups per hour. "Check up front, hit() on failure only" mirrors the Mage_Sales_Helper_Guest pattern so a customer holding several legitimate cards isn't penalised for genuine lookups. Scope is intentionally just this page; the existing checkout applyGiftcardToCart path is a separate concern.

Polish

  • Admin form balance fields display to 2dp (balance and initial_balance columns remain decimal(12,4) for arithmetic precision; trailing zeros were leaking to the input as 30.0000)
  • en_US locale CSV updated for all new translatable strings
  • data-turbo="false" on the balance form so Hotwire-Turbo installs get the standard browser POST→redirect→GET flow and don't accumulate flash messages

Verification

  • composer lint:cs-fixer, lint:phpstan, lint:rector — all clean on the 14 PHP files in the diff
  • Manually exercised on a multi-website install: admin form save, grid filter, history tab, customer balance lookup with valid/invalid codes, rate-limit trip after 10 failed attempts, locale strings render translated (not raw English fallback)

Commits

feat(giftcard): add giftcard_website junction + 1.0.0→1.1.0 backfill
feat(giftcard): persist website_ids via junction, validate per-website
feat(giftcard): admin form multiselect + edit-mode reassign
fix(giftcard): admin grid renders + filters website membership
feat(giftcard): admin edit page transaction-history tab
feat(giftcard): customer "Check Balance" My Account page
chore(giftcard): display admin balance fields to 2dp
feat(giftcard): rate-limit failed customer balance lookups
chore(giftcard): add en_US locale strings for new features
chore(giftcard): satisfy PHPStan + house style on new tab/controller code

Matthew Campbell and others added 14 commits June 25, 2026 16:11
A single website_id scalar on giftcard ties each card to exactly one website,
which doesn't reflect how multi-website stores actually use gift cards in
practice (head office issues a single card that customers spend across any of
the brand's storefronts). This adds a junction table so a card can be valid
on an arbitrary subset of websites without duplicating the card row.

- giftcard_website (giftcard_id, website_id) with the pair as PK, indexed by
  website_id for the apply-time validity lookup, FKs to giftcard and
  core_website both ON DELETE CASCADE so cleaning up either side stays simple.
- giftcard.website_id kept and made nullable for backward compatibility with
  any external code still reading the scalar; the authoritative association
  becomes the junction. Scheduled for removal in a future major.
- upgrade-1.0.0-1.1.0.php backfills the junction by CROSS JOINing existing
  cards with every active website (website_id > 0). Permissive on purpose:
  no existing card loses validity over the upgrade; operators tighten the
  scope per-card from the admin multiselect introduced in a follow-up commit.
  INSERT IGNORE so a rebuild can re-run the script without errors.

Subsequent commits in this PR wire the model, resource, admin form, admin
grid, customer "Check Balance" page, and admin history tab to the junction.
Switches the model's website handling to the new giftcard_website junction.

- Model:
  - getWebsiteIds() lazy-loads the associations from the junction and memoises
    them under the `website_ids` data key.
  - setWebsiteIds() takes an int[], dedupes and ignores 0/negative ids, and
    stores the set on the model; persistence happens in the resource's
    _afterSave hook.
  - isValidForWebsite() switches from `(int) website_id === target` to an
    in_array check against the junction. A card with an empty website set
    fails validation everywhere — fail closed rather than silently treating
    "no associations" as "valid anywhere", which would let a botched import
    or hand-crafted row drain balance from any storefront.

- Resource:
  - _afterLoad hydrates `website_ids` so subsequent getWebsiteIds() reads
    don't re-query the junction.
  - _afterSave syncs the junction inside a transaction: delete the previous
    rows for this card, insert the new set. Rolls back on any partial-write
    error so the card never ends up with a torn association set.
  - getWebsiteIds(int $giftcardId) is the single read path, used by the
    model's lazy loader and reusable by future callers (collection, grid,
    customer balance lookup).
  - Skipped entirely when no `website_ids` was set on the model — a save
    that only touched balance/status won't trample the associations.
Replaces the single-website select on the gift card edit form with a
multiselect, and removes the "existing cards show website read-only"
branch — operators can re-scope an issued card's websites at any time
without having to delete and reissue.

- New card: multiselect defaults to the first available website (the
  admin's current scope) so create-flow keys, currency derivation and
  the legacy giftcard.website_id FK all keep working.
- Existing card: multiselect pre-selects the websites already in the
  giftcard_website junction. If the row hasn't been backfilled yet
  (e.g. a fresh upgrade running before the data-migration script),
  fall back to the legacy website_id scalar so the field isn't blank.

Controller:
- Normalises the multiselect post into a clean int[] (dedupe, drop
  empty/0 ids, accept either an array or a stray single value if an
  older form posts the old `website_id` name).
- Falls back to the current admin website when no selection was posted,
  so saves never land with an empty association set that would orphan
  the card from every storefront.
- Sets legacy `website_id` to the first selection so the existing
  currency derivation and FK back to core_website keep working alongside
  the junction.

Note text on the multiselect calls out the first-selection currency
rule so admins picking websites with mixed base currencies notice the
implicit conversion before issuing the card.
The grid was indexing on giftcard.website_id — a scalar that no longer
represents the full association set, and pre-1.1.0 a row could perfectly
legitimately have a value the operator never asked about. Switches the grid
to read from the giftcard_website junction.

- _prepareCollection LEFT JOINs the junction and selects GROUP_CONCAT'd
  website ids into a `website_ids` alias, grouping by giftcard_id. LEFT JOIN
  so orphaned rows (no junction entries) still appear in the listing — the
  renderer surfaces them as an em dash so the operator can re-scope them
  from the edit page rather than silently filtering them out.
- The Websites column is filterable + sortable on the alias. A custom
  filter_condition_callback translates the column dropdown's single
  website_id into a `HAVING FIND_IN_SET(N, website_ids)` (HAVING because the
  alias is computed, not a raw column reference).
- New renderer Maho_Giftcard_Block_Adminhtml_Giftcard_Renderer_Websites
  maps the GROUP_CONCAT'd ids to comma-separated website names via the
  admin system-store helper hash; unknown ids fall back to "[id N]" so a
  deleted-website row stays visibly anomalous instead of crashing.

Indirectly fixes the prior "card invisible after creation" bug in
multi-website installs, where the grid's old column tried to read a
website_id that the save flow wasn't persisting reliably.
Admins tracing where a card's balance went currently have to leave the card
to scan the global Sales → Gift Card History page, then filter or remember
the giftcard_id. This adds an in-context "Transaction History" tab on the
edit page so the audit trail lives next to the card it describes.

Implementation reuses the existing standalone History/Grid:

- New Edit/Tabs container wraps the edit form ("General" tab) and the new
  history tab. The History tab is only added when a card has been saved —
  the create form stays a single-screen flow.
- New Edit/Tab/History extends the existing History/Grid and scopes the
  collection to the current card via the registered current_giftcard. Mass
  actions and row-click are suppressed because this view is a read-only
  audit trail, not a launchpad.
- Edit container's _prepareLayout swaps its auto-resolved Form child for
  the Tabs container so the existing Form_Container template's
  `<form id="edit_form">` wrapper still posts the form fields normally;
  the read-only history tab introduces no inputs.
- New historyGridAction in the admin controller is the AJAX endpoint the
  inner Mage_Adminhtml_Block_Widget_Grid uses for sort/filter/paginate
  reloads; it re-registers the gift card from the id query param so the
  scope matches the parent edit page.
Adds a customer-facing page under My Account where a logged-in shopper can
look up the remaining balance + expiry on a gift card by code. No history
view — that stays admin-only (transaction comments are operational notes
not meant for the customer-facing audit trail).

Files:

- controllers/BalanceController.php — front-area controller with two
  attribute-routed actions:
    - GET  /giftcard/balance        renders the lookup form
    - POST /giftcard/balance/check  validates and stashes the result
  preDispatch enforces customer auth on both, matching every other
  customer/account/* action so a deep link while signed out hands off to
  the standard login flow.

  The check action treats "not found", "expired", "disabled" and "no
  website membership" as the same opaque "could not be found" response —
  this endpoint is not an oracle for walking codes.

- Block/Customer/Balance.php — pulls the most recent result out of
  giftcard/session and consumes it on render so a back/forward navigation
  doesn't re-display the previous customer's check on a shared device.

- Model/Session.php — per-customer session namespace for the result +
  flash messages.

- app/design/frontend/base/default/layout/giftcard.xml — adds the
  navigation link to customer_account and registers the page handle.

- app/design/frontend/base/default/template/giftcard/customer/balance.phtml
  — pure base-theme markup using box-account / form-list / buttons-set /
  required pattern so any storefront theme inheriting from base/default
  picks the page up without overriding a single class.
The `balance` and `initial_balance` columns are stored as decimal(12,4)
so arithmetic over partial redemptions, currency conversion, and post-tax
amounts doesn't accumulate rounding error. The admin form was leaking the
storage precision straight to the input field — "30.0000" — which reads as
unfinished UX rather than precise bookkeeping.

Format both fields to 2dp before passing them to the form's setValues().
The DB column is untouched; saves still land back as decimal(12,4) and
the existing balance-adjustment history captures the audit trail at full
precision.
The My Account "Check Gift Card Balance" page accepts a code in a form
input. Authentication keeps anonymous bots out, but a logged-in account
could still walk codes — sequential card formats (think "GC-XXXX-XXXX"
prefixes) are exactly the kind of namespace this enables. With no limit
the attack cost is one HTTP round-trip per attempt.

Add per-customer rate limiting via the shared
Mage\Core\Helper\Data::rateLimiterBy() factory (scope key = customer id),
10 failed lookups per hour. "Check upfront, hit() on failure only"
mirrors the Mage_Sales_Helper_Guest pattern so a customer holding several
legitimate cards isn't penalised for genuine lookups — only mis-attempts
count toward the window.

Errors stay opaque ("could not find an active gift card") so the limiter
itself doesn't leak which side of the failure the attacker hit; when
they cross the threshold they see a generic "Too many recent lookup
attempts" message and have to wait the window out.
AGENTS.md requires every translatable string (`$this->__()` or
`helper()->__()`) to land in app/locale/en_US/<Module>.csv in the same
commit as the code that introduces it. This sweep covers the strings
added across the multi-website + customer balance PR:

- Admin form: "Websites", "Hold Ctrl/Cmd to select multiple. …"
- Admin edit-page tabs: "General", "Transaction History"
- Customer balance page: "Check Gift Card Balance", "Gift Card Balance"
  (My Account nav link), "Customer Gift Card Balance" (page handle),
  "Enter your gift card code", "Enter the code printed on…", "* Required
  Fields", "Check Balance", "Balance for %s", "Current balance:",
  "Expires:", "Back to My Account", "We could not find an active gift
  card for that code on this store."
- Rate limit: "Too many recent lookup attempts. Please wait a while
  before trying again."

CSV stays alphabetically sorted per house style.
…code

Sweeps lint findings against the rest of the multi-website + customer
balance PR:

- Edit/History.php and Edit/Tab/Form.php now carry `#[\Override]` on
  the four Tab_Interface methods (`getTabLabel`, `getTabTitle`,
  `canShowTab`, `isHidden`) — house-rule per `maho-coding-guidelines`
  and PHPStan's `method.missingOverride` rule.

- Grid.php having() filter callback hands the raw SQL string instead
  of wrapping it in `Maho\Db\Expr` — the having() signature types its
  `$cond` as string and the Expr wasn't buying anything here.

- Edit/Tab/Form.php drops a redundant `!== null` check after `isset()`
  on the 2dp balance formatting block. `isset` already excludes null.

- Block/Customer/Balance.php constructs its typed return shape
  explicitly from the session payload rather than handing the raw
  array through and hoping it matches the `@return array{...}` docblock.

- BalanceController::preDispatch returns `$this` so the override
  matches the parent's `@return $this(...)` signature.

- BalanceController::checkAction adds a `@var` for the resource model
  before calling `loadByCode($card, $code)` — PHPStan otherwise sees
  the abstract `Mage_Core_Model_Resource_Db_Abstract` return type and
  doesn't know the giftcard resource defines `loadByCode`.

Verified clean against `composer lint:cs-fixer`, `lint:phpstan` and
`lint:rector` on the PR diff.
CI surfaced that switching `isValidForWebsite()` to a junction-membership
check broke the back-compat path: any code that sets the legacy
`website_id` scalar directly (programmatic creation, imports, fixtures,
the existing Pest tests) but doesn't also call `setWebsiteIds()` produces
an empty junction and `isValidForWebsite()` then fails closed everywhere.
The 1.0.0→1.1.0 backfill handles existing rows on upgrade, but new
unmigrated saves are still legitimate.

`getWebsiteIds()` now falls back to `[(int) $this->getWebsiteId()]` when
the junction is empty and the legacy scalar is set. Junction wins when
present (multi-website cards behave as designed); single-website legacy
cards keep working without explicit junction population.

Affected tests:
- GiftcardTest::isValidForWebsite (unit)
- MultistoreMulticurrencyTest (8 cases under integration)
- OrderFlowTest::isValidForWebsite (integration)
- Total\QuoteTest::balance application (integration)

All set the legacy scalar via setWebsiteId() / setData('website_id', N)
and expect isValidForWebsite() to return true.
The balance lookup page accumulated error messages across submits — each
failed lookup adding to giftcard/session, none being cleared between
GETs. The custom session storage needed wiring into the messages block's
storage types via `_initLayoutMessages('giftcard/session')`, and even
then the lifecycle wasn't clearing reliably (suspected Hotwire-Turbo
interaction on themes where it's enabled).

Switching to customer/session for the user-facing flash messages drops
the whole custom-session-wiring path: customer/session is the standard
storage the My Account messages block reads from and clears via the page
chrome on every render. giftcard/session stays in use, but now only for
the lookup result payload (a one-shot data hand-off between the POST
handler and the GET renderer) — no flash messages, no clearing concern.
- saveAction: keep model on old balance before adjustBalance() so the
  recorded history delta and final balance are both correct (was logging
  a zero-amount adjustment)
- saveAction: add to _setForcedFormKeyActions for CSRF protection
- checkBalanceAction: use Mage::helper('core')->jsonEncode() instead of
  native json_encode()
- BalanceController::checkAction: validate the form key explicitly so a
  cross-site POST can't burn a customer's rate-limit slots
- Resource\Giftcard: stop populating website_ids in _afterLoad; it
  defeated the _afterSave skip-guard (redundant junction rewrites on
  every save, and an exception when isValid() auto-expires an orphaned
  card). getWebsiteIds() already lazy-loads on demand
- admin currency-hint JS: target the website_ids multiselect and read
  selectedOptions[0] (was the stale single-select website_id id)
- balance.phtml: correct SPDX license to AFL-3.0 for templates
@fballiano

Copy link
Copy Markdown
Contributor

Reviewed in depth (logic / security / conventions) and pushed fixes in e36a83d. Two correctness bugs plus a CSRF gap were the notable ones:

Correctness

  • saveAction was recording a zero-amount balance-adjustment history entry — adjustBalance() ran after the new balance was already set, so the delta computed as 0. Now keeps the model on the old balance through the first save so both the history delta and the final balance are correct.
  • Model/Resource/Giftcard::_afterLoad() populated website_ids on every load, which defeated the _afterSave skip-guard: junction rows were deleted+reinserted on every save (checkout/refund hot paths), and an orphaned card with no associations threw "must be associated with at least one website" when isValid() auto-expired it — surfacing as a 500 in BalanceController::checkAction. Removed the population; getWebsiteIds() already lazy-loads on demand, so the guard now behaves as documented (null = untouched = skip, array = explicitly set = sync).

Security

  • BalanceController::checkAction is a state-changing POST but didn't validate the form key (frontend doesn't do this automatically). A cross-site POST could burn a logged-in customer's rate-limit slots and lock them out for an hour. Added an explicit _validateFormKey(), matching CartController.
  • Added save to _setForcedFormKeyActions() on the admin controller.

Conventions / polish

  • checkBalanceAction: native json_encode()Mage::helper('core')->jsonEncode().
  • Admin currency-hint JS targeted the stale website_id id; updated to the website_ids multiselect with selectedOptions[0], so the currency hint updates again.
  • balance.phtml SPDX corrected to AFL-3.0 (templates are AFL, not OSL).

Verification: composer lint (phpstan/cs-fixer/rector) all clean, and the giftcard test suite passes (193/193). The multi-website model, junction sync (transactional, fail-closed on empty set), opaque-error enumeration defense, and per-customer rate limiting all hold up.

@fballiano fballiano added this to the 26.7.0 milestone Jun 25, 2026
…r render

PR MahoCommerce#1059 marked Form_Container::getSaveUrl() with #[\Deprecated], but
getFormHtml() still calls it on every admin form-container render. Under
PHP 8.4 the attribute raises E_USER_DEPRECATED at runtime, surfacing as a
hard error on admin edit pages (gift card edit, etc.). getSaveUrl() is a
thin alias for getFormActionUrl(); call the latter directly.
The getFormHtml() call to the deprecated getSaveUrl() was migrated to
getFormActionUrl(); remove the now-unmatched baseline ignore so PHPStan
(reportUnmatchedIgnoredErrors) stops failing CI.
The 1.0.0->1.1.0 junction backfill is pure DML, so it belongs in the
data-setup phase (applyDataUpdates), not sql schema setup. Relocate it
to data/giftcard_setup/data-upgrade-1.0.0-1.1.0.php so it runs after the
declarative schema creates giftcard_website. Content unchanged.
@fballiano

Copy link
Copy Markdown
Contributor

Thank you @mageaustralia!

Question: what if a giftcard is associated to 2 websites with 2 different currencies? since there's no currency directly tied to the giftcard it kinda worries me

@fballiano

Copy link
Copy Markdown
Contributor

I think we have 2 ways:

  1. Constrain the multiselect to websites that share the card's base currency. Keeps the whole
    stored-value model intact; "multi-website" just means "multiple same-currency storefronts" (the common head-office case the PR description actually cites). Minimal change, fully correct.
  2. Make currency explicit on the card (currency_code column, frozen at issue) and then convert at every redeem/use

I'm might be missing something here but with multiwebsite I don't think it's safe to leave it as it is.

what do you think?

@mageaustralia

Copy link
Copy Markdown
Contributor Author

@fballiano - You're always one step ahead.

I'd probably go with restrict associations to currency-matched websites . Validate in _beforeSave() that all website_ids share the same getBaseCurrencyCode() in the immediate term:

Longer term options:

  1. Add currency_code to the giftcard + convert on application. Card is denominated in one currency, redeemed elsewhere via Maho's currency rates. This would be more flexible, but rates are merchant-controlled and balance arithmetic across rate changes gets ugly (partial redemptions, refunds, history reconciliation).

  2. Per-website balance in the junction. Schema becomes (giftcard_id, website_id, balance). Each store tracks its own pot, conversion locked at first redemption. Most flexible, this could get complex - refund/reversal semantics are non-trivial

What do you think?

My particular use case is that we have the online store and we have a separate store for POS - POS handles things a bit differently to live, mostly that it allows purchasing disabled / out of stock products. It also allows us to track In Store sales vs Online Sales easily but it also requires allowing the use of a GC purchased online to be used in-store.

@fballiano

Copy link
Copy Markdown
Contributor

@mageaustralia as a first implementation it's ok but I wonder how could we enforce it, like what if you edit a giftcard and change the websites completely to another currency. i mean it's an edge case but..

Also, would it be possible to keep the website_ids as CSV in the giftcard column? It's only OCD but the new table and the migration script with the leftover column (a real limitation of declarative schema) trigger me a bit. It's true that the separated table is a standard in our codebase..

@mageaustralia

Copy link
Copy Markdown
Contributor Author

@fballiano — sorry, I've been on holidays the last week, so slow to reply to this.

Two solid points, replying to both:

On the currency edit-mid-life concern

Rather than a _beforeSave guard, I think the cleaner fix is at the UI layer: only display websites in the multiselect whose base currency matches the card's currency. Ones that don't match are shown but greyed out with the currency printed next to the label — so the admin can see "Store B (EUR)" is unselectable and understand why immediately, without needing to know the internal rule.

That makes the mismatch impossible to save in the first place without an error message being needed, and covers the edit-mid-life case naturally (once the card has a currency, the picker only offers same-currency websites).

On the schema — CSV column vs junction

Happy to do whatever you like here. I was trying to stick with Maho conventions — every multi-website relationship in core (catalog_product_website, salesrule_website, catalogrule_website, catalogrule_group_website) uses a junction, so that's what I reached for. But if you'd prefer CSV on the giftcard row, I'll switch it — say the word and I'll rework this in the same commit as the currency picker fix.

…the legacy website_id column

- sql/schema.php no longer declares website_id (nor its index/FK): the
  declarative pass preserves the column via additive merge until the new
  sql/giftcard_setup/upgrade-1.0.0-1.1.0.php backfills the junction from it
  and drops it. Fresh installs skip the guarded block; re-runs are no-ops.
- Backfill is conservative (one row per card: its original website) instead
  of the previous permissive data-phase script (every card on every active
  website), which would have made existing cards spendable cross-website at
  face value in other currencies - pre-1.1.0 validation was a strict website
  match, not store-agnostic.
- Repointed every remaining scalar reader to the junction: currency
  derivation (getWebsite), cart/observer/checkout website validation (still
  comparing the scalar, so multi-website cards were rejected outside their
  first website), admin save/form/JSON, grid and history-grid currency
  lookups, purchase-flow card creation. Removed setWebsiteId entirely.
- New cards saved without explicit associations default to the current
  website; an explicit empty set still fails closed.
- All websites associated to one card must share a base currency (the card
  balance is denominated in it), enforced at save.
- getWebsiteIds() no longer memoises lazy reads into the pending-change data
  key, so balance saves on the checkout path stop rewriting the junction.
- Added Collection::addWebsiteFilter() plus website-association tests,
  including one that rebuilds the pre-1.1.0 schema and exercises the upgrade
  script end to end.
@fballiano

Copy link
Copy Markdown
Contributor

Pushed a8a7aba: the website_id scalar is now fully migrated to the giftcard_website junction and dropped — no leftover column.

How the migration works (it turns out the declarative schema handles this fine): sql/schema.php simply stops declaring website_id + its index/FK. The declarative pass never drops columns (additive merge in the Canonicalizer — only undeclared indexes/FKs are removed), so on existing installs the data survives the schema pass, and the new sql/giftcard_setup/upgrade-1.0.0-1.1.0.php then backfills the junction and drops the column itself. Fresh installs skip the guarded block entirely; re-runs are no-ops; fresh and upgraded installs converge to identical schemas.

Backfill is now conservative, not permissive. The data-phase script associated every existing card with every active website — but pre-1.1.0 validation was a strict website match (Total/QuoteisValidForWebsite()website_id === $websiteId), not store-agnostic. The permissive backfill would have made every existing card spendable on every website at face value, including cross-currency ones. The new backfill is one row per card (its original website), preserving behaviour exactly; operators broaden per card from the admin page — which also fits your POS use case (associate the online cards with the POS website once, deliberately).

Latent bug fixed along the way: CartController (×3), the quote observer, and core Checkout/CartController were still validating against the scalar, so a card associated to websites [2, 3] would be rejected at cart-apply on website 3 even though the junction said it's valid. All entry paths now do junction membership checks.

Also in this commit: same-currency guard on save (all associated websites must share one base currency — the server half of the picker rule we discussed; the greyed-out multiselect UI is still all yours if you want to add it on top), default-to-current-website for programmatic saves (restores the pre-1.1.0 default), getWebsiteIds() no longer marks lazy reads as pending changes (junction was being rewritten on every checkout balance save), Collection::addWebsiteFilter(), setWebsiteId() removed outright (new module, no BC baggage), and a new WebsiteAssociationTest that rebuilds the pre-1.1.0 schema and exercises the upgrade script end to end.

Note for dev machines that already ran this branch: your core_resource row for giftcard_setup is already at 1.1.0, so the new upgrade script won't fire. Either ./maho db:query "UPDATE core_resource SET version='1.0.0' WHERE code='giftcard_setup'" and re-run ./maho migrate, or drop the column manually.

Verification: composer lint clean (the one PHPStan error on the branch is an unrelated Mage_Api2 ignore-entry leftover from the main merge), full Backend suite green (2242 passed), giftcard suite 200/200 including the new migration test.

…ebsite-and-customer-balance

# Conflicts:
#	app/code/core/Maho/Giftcard/controllers/Adminhtml/GiftcardController.php
@fballiano

Copy link
Copy Markdown
Contributor

Merged current main into the branch (4bfe2e8) and adapted the giftcard code the merge brought in:

  • Model creation defaults: main consolidated code/expiration/initial-balance defaults into Giftcard::_beforeSave() — merged the branch's junction default into it (website_ids → current website when nothing was set) and dropped the now-redundant blocks from the admin controller's saveAction.
  • New API Platform resource (Maho/Giftcard/Api/* from Rewrote the API on API Platform: REST v2, GraphQL, JWT auth, granular permissions and OpenAPI docs #578) still targeted the dropped scalar: the GraphQL create mutation's websiteId Int arg wrote website_id, which would have been silently discarded at save. It's now websiteIds [Int!] feeding setWebsiteIds() (defaults to the current website when omitted), and the REST/GraphQL DTO exposes websiteIds — writable on create, populated from the junction on reads. The public checkBalance projection intentionally does not include website associations.

Verification after the merge: composer lint fully clean (including PHPStan — the Api2 ignore-entry issue is gone with current main), Backend suite 2332 passed, Api suite 565 passed (includes the GiftCard REST v2 read/write tests).

@fballiano fballiano modified the milestones: 26.7.0, 26.9.0 Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants