Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4857ace
feat(giftcard): add giftcard_website junction + 1.0.0→1.1.0 backfill
Jun 25, 2026
cd406b8
feat(giftcard): persist website_ids via junction, validate per-website
Jun 25, 2026
f747d2b
feat(giftcard): admin form multiselect + edit-mode reassign
Jun 25, 2026
913cc54
fix(giftcard): admin grid renders + filters website membership
Jun 25, 2026
a8ad1b8
feat(giftcard): admin edit page transaction-history tab
Jun 25, 2026
d2b4db6
feat(giftcard): customer "Check Balance" My Account page
Jun 25, 2026
f53e3cd
chore(giftcard): display admin balance fields to 2dp
Jun 25, 2026
b9f71f3
feat(giftcard): rate-limit failed customer balance lookups
Jun 25, 2026
b715233
chore(giftcard): add en_US locale strings for new features
Jun 25, 2026
0bdc2a6
chore(giftcard): satisfy PHPStan + house style on new tab/controller …
Jun 25, 2026
9c031d3
fix(giftcard): preserve legacy website_id fallback in getWebsiteIds()
Jun 25, 2026
3702e0d
fix(giftcard): route customer balance flash errors via customer/session
Jun 25, 2026
0bb4af1
lint
fballiano Jun 25, 2026
e36a83d
fix(giftcard): address review findings on multi-website + balance page
fballiano Jun 25, 2026
9d4eaac
fix(adminhtml): stop calling deprecated getSaveUrl() in form containe…
fballiano Jun 25, 2026
7ecfa20
chore(phpstan): drop stale getSaveUrl() deprecation baseline entry
fballiano Jun 25, 2026
f504848
refactor(giftcard): move junction backfill to data setup phase
fballiano Jun 25, 2026
866b007
Merge branch 'main' into feat/giftcard-multi-website-and-customer-bal…
fballiano Jun 25, 2026
a8a7aba
Migrated gift cards fully to the giftcard_website junction, dropping …
fballiano Jul 15, 2026
838b055
Removed redundant migration comment from giftcard schema.php
fballiano Jul 15, 2026
4bfe2e8
Merge remote-tracking branch 'origin/main' into feat/giftcard-multi-w…
fballiano Jul 15, 2026
8acf006
Merge branch 'main' into feat/giftcard-multi-website-and-customer-bal…
fballiano Jul 24, 2026
f04b6b0
Merge branch 'main' into feat/giftcard-multi-website-and-customer-bal…
fballiano Jul 25, 2026
0e89e4d
Merge branch 'main' into feat/giftcard-multi-website-and-customer-bal…
fballiano Jul 26, 2026
5f51382
Merge branch 'main' into feat/giftcard-multi-website-and-customer-bal…
fballiano Aug 1, 2026
15c89b2
Merge remote-tracking branch 'origin/main' into feat/giftcard-multi-w…
fballiano Aug 11, 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
2 changes: 1 addition & 1 deletion app/code/core/Mage/Checkout/controllers/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ protected function _tryApplyGiftcard(string $code): array

// Check website validity
$websiteId = (int) $quote->getStore()->getWebsiteId();
if ((int) $giftcard->getWebsiteId() !== $websiteId) {
if (!in_array($websiteId, $giftcard->getWebsiteIds(), true)) {
return ['success' => false, 'message' => ''];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,45 @@ protected function assertWebsiteAllowed(int|string|null $websiteId, ApiUser $use
}
}

/**
* Read gate for an entity associated with a set of websites: deny when none
* of them is within the user's scope. An empty set is denied, matching the
* single-website rule.
*
* @param int[] $entityWebsiteIds
*/
protected function assertAnyWebsiteAllowed(array $entityWebsiteIds, ApiUser $user, string $entityLabel): void
{
$websiteIds = $this->allowedWebsiteIds($user);
if ($websiteIds === null) {
return;
}

if (array_intersect(array_map('intval', $entityWebsiteIds), $websiteIds) === []) {
throw new AccessDeniedHttpException("Access denied for this {$entityLabel}'s website");
}
}

/**
* Write gate for an entity associated with a set of websites: deny unless
* every one of them is within the user's scope, so a restricted token
* cannot edit a record that also reaches a website it cannot see.
*
* @param int[] $entityWebsiteIds
*/
protected function assertAllWebsitesAllowed(array $entityWebsiteIds, ApiUser $user, string $entityLabel): void
{
$websiteIds = $this->allowedWebsiteIds($user);
if ($websiteIds === null) {
return;
}

$entityWebsiteIds = array_map('intval', $entityWebsiteIds);
if ($entityWebsiteIds === [] || array_diff($entityWebsiteIds, $websiteIds) !== []) {
throw new AccessDeniedHttpException("Access denied for this {$entityLabel}'s website");
}
}

/**
* Map a restricted user's allowed store ids to their website ids; null for
* unrestricted users, meaning "no website restriction". An allowlisted
Expand Down
18 changes: 16 additions & 2 deletions app/code/core/Maho/Giftcard/Api/GiftCard.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
'senderName' => ['type' => 'String', 'description' => 'Sender name'],
'senderEmail' => ['type' => 'String', 'description' => 'Sender email'],
'message' => ['type' => 'String', 'description' => 'Gift card message'],
'websiteId' => ['type' => 'Int', 'description' => 'Website ID (defaults to current)'],
'websiteIds' => ['type' => '[Int!]', 'description' => 'Website IDs the card is valid on (defaults to current website)'],
'expiresAt' => ['type' => 'String', 'description' => 'Expiration date (YYYY-MM-DD)'],
],
description: 'Create a new gift card',
Expand Down Expand Up @@ -115,7 +115,6 @@ class GiftCard extends CrudResource
public ?string $status = null;

public ?string $expiresAt = null;
public ?int $websiteId = null;

#[ApiProperty(writable: false)]
public ?string $currencyCode = null;
Expand Down Expand Up @@ -144,6 +143,15 @@ class GiftCard extends CrudResource
public ?string $senderEmail = null;
public ?string $message = null;

/**
* Websites the card is valid on (giftcard_website junction). Maps to the
* model's `website_ids` pending-change key, so applyToModel() feeds the
* junction sync; omitting it on create falls back to the current website.
*
* @var int[]|null
*/
public ?array $websiteIds = null;

/** Not persisted on the card: recorded as the giftcard_history comment of a balance adjustment. */
#[ApiProperty(readable: false)]
public ?string $comment = null;
Expand All @@ -161,6 +169,12 @@ public static function afterLoad(self $dto, object $model): void
{
$dto->currencyCode = $model->getCurrencyCode();

// The junction is lazy-loaded, never present in the model's data
// array, so fromModel()'s convention mapping leaves this null.
if ($model instanceof \Maho_Giftcard_Model_Giftcard && $model->getId()) {
$dto->websiteIds = $model->getWebsiteIds();
}

foreach ($model->getHistoryCollection() as $entry) {
$dto->history[] = [
'action' => $entry->getData('action'),
Expand Down
63 changes: 35 additions & 28 deletions app/code/core/Maho/Giftcard/Api/GiftCardProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,26 @@ protected function afterSave(object $model, CrudResource $data): void
}

/**
* REST create path: default an omitted websiteId to the current store's
* website (a NULL-website card is unredeemable everywhere), then enforce
* the token's website scope on the resolved value.
* REST create path: default omitted websiteIds to the current store's
* website (a card with no website is unredeemable everywhere), then enforce
* the token's website scope on the resolved set.
*/
#[\Override]
protected function beforeSave(object $model, CrudResource $data, ApiUser $user): void
{
if (!$model->getId() && !$model->getData('website_id')) {
$model->setData('website_id', (int) StoreContext::getStore()->getWebsiteId());
if (!$model->getId() && $model->getData('website_ids') === null) {
$model->setWebsiteIds([(int) StoreContext::getStore()->getWebsiteId()]);
}
if ($model->getId()) {
// The card as stored must be in scope too, or a restricted token
// could claim a foreign card by rewriting its websiteId.
$this->assertWebsiteAllowed($model->getOrigData('website_id'), $user, 'gift card');
// could claim a foreign card by rewriting its websiteIds.
$this->assertAllWebsitesAllowed(
$model->getResource()->getWebsiteIds((int) $model->getId()),
$user,
'gift card',
);
}
$this->assertWebsiteAllowed((int) $model->getData('website_id'), $user, 'gift card');
$this->assertAllWebsitesAllowed($model->getWebsiteIds(), $user, 'gift card');
}

/** Balance bounds and duplicate-code check for the REST CRUD create/update path. */
Expand All @@ -85,12 +89,17 @@ protected function validate(CrudResource $data, object $model, bool $isNew): voi
$this->assertBalanceBounds((float) $data->balance);
}
$this->assertValidStatus($data->status);
if ($data->websiteId !== null) {
try {
\Mage::app()->getWebsite($data->websiteId);
} catch (\Throwable) {
throw new BadRequestHttpException("Unknown website id {$data->websiteId}");
}
foreach ($data->websiteIds ?? [] as $websiteId) {
$this->assertKnownWebsite((int) $websiteId);
}
}

private function assertKnownWebsite(int $websiteId): void
{
try {
\Mage::app()->getWebsite($websiteId);
} catch (\Throwable) {
throw new BadRequestHttpException("Unknown website id {$websiteId}");
}
}

Expand Down Expand Up @@ -118,7 +127,7 @@ protected function processUpdate(int $id, mixed $data, ApiUser $user): mixed

/** @var \Maho_Giftcard_Model_Giftcard $model */
$model = $this->loadOrFail($this->modelAlias, $id, 'Gift card not found');
$this->assertWebsiteAllowed($model->getWebsiteId(), $user, 'gift card');
$this->assertAllWebsitesAllowed($model->getWebsiteIds(), $user, 'gift card');
$oldData = $model->getData();

$this->assertValidStatus($data->status);
Expand Down Expand Up @@ -186,18 +195,16 @@ private function createGiftcardFromGraphQl(array $context): GiftCard
null,
);

// Default an omitted websiteId to the current store's website (the
// documented behavior; a NULL-website card is unredeemable everywhere)
// and enforce the token's website scope on the resolved value.
$websiteId = isset($args['websiteId'])
? (int) $args['websiteId']
: (int) StoreContext::getStore()->getWebsiteId();
try {
\Mage::app()->getWebsite($websiteId);
} catch (\Throwable) {
throw new BadRequestHttpException("Unknown website id {$websiteId}");
// Default omitted websiteIds to the current store's website (the
// documented behavior; a card with no website is unredeemable
// everywhere) and enforce the token's website scope on the resolved set.
$websiteIds = empty($args['websiteIds'])
? [(int) StoreContext::getStore()->getWebsiteId()]
: array_map('intval', (array) $args['websiteIds']);
foreach ($websiteIds as $websiteId) {
$this->assertKnownWebsite($websiteId);
}
$this->assertWebsiteAllowed($websiteId, $this->requireUser(), 'gift card');
$this->assertAllWebsitesAllowed($websiteIds, $this->requireUser(), 'gift card');

$giftcard = \Mage::getModel('giftcard/giftcard');
$giftcard->setData([
Expand All @@ -209,9 +216,9 @@ private function createGiftcardFromGraphQl(array $context): GiftCard
'sender_name' => $args['senderName'] ?? null,
'sender_email' => $args['senderEmail'] ?? null,
'message' => $args['message'] ?? null,
'website_id' => $websiteId,
'expires_at' => $args['expiresAt'] ?? null,
]);
$giftcard->setWebsiteIds($websiteIds);
$giftcard->save();
$this->sendEmailToRecipient($giftcard);

Expand Down Expand Up @@ -251,7 +258,7 @@ private function adjustBalance(array $context): GiftCard
throw new NotFoundHttpException('Gift card not found');
}

$this->assertWebsiteAllowed($giftcard->getWebsiteId(), $this->requireUser(), 'gift card');
$this->assertAllWebsitesAllowed($giftcard->getWebsiteIds(), $this->requireUser(), 'gift card');

$this->assertBalanceBounds($newBalance);
$giftcard->adjustBalance($newBalance, $args['comment'] ?? null);
Expand Down
20 changes: 12 additions & 8 deletions app/code/core/Maho/Giftcard/Api/GiftCardProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ protected function handleOperation(string $name, array $context, array $uriVaria
}

$giftcard = \Mage::getModel('giftcard/giftcard')->loadByCode(trim($code));
// A card is only redeemable on its own website, so another
// website's store must not answer for it either.
// A card is only redeemable on the websites it is associated with,
// so any other website's store must not answer for it either.
if (!$giftcard->getId()
|| (int) $giftcard->getWebsiteId() !== (int) StoreContext::getStore()->getWebsiteId()
|| !in_array((int) StoreContext::getStore()->getWebsiteId(), $giftcard->getWebsiteIds(), true)
) {
throw new \RuntimeException('Gift card not found');
}
Expand All @@ -51,28 +51,32 @@ protected function handleOperation(string $name, array $context, array $uriVaria
}

/**
* Admin/service item read, restricted tokens only see cards on their
* allowed websites.
* Admin/service item read, restricted tokens only see cards reaching at
* least one of their allowed websites.
*/
#[\Override]
protected function provideItem(int|string $id): ?Resource
{
$dto = parent::provideItem($id);
if ($dto instanceof GiftCard) {
$this->assertWebsiteAllowed($dto->websiteId, $this->requireUser(), 'gift card');
$this->assertAnyWebsiteAllowed($dto->websiteIds ?? [], $this->requireUser(), 'gift card');
}
return $dto;
}

/**
* Admin/service list, restricted tokens only see cards on their allowed
* websites.
* websites. Membership lives in the giftcard_website junction, so this
* cannot use the shared main_table.website_id filter.
*/
#[\Override]
protected function applyCollectionFilters(object $collection, array $filters): void
{
parent::applyCollectionFilters($collection, $filters);
$this->applyAllowedWebsiteFilter($collection, $this->requireUser());
$allowedWebsiteIds = $this->allowedWebsiteIds($this->requireUser());
if ($allowedWebsiteIds !== null) {
$collection->addWebsiteIdsFilter($allowedWebsiteIds);
}
}

/**
Expand Down
Loading
Loading