Skip to content

Multi-Market Implementation: Phase 3 - Services Layer

Status: Planning

Dependencies

Requires completed: - Phase 1 (Entities) - Market, AffiliateProgram, BuyingOption entities - Phase 2 (Migration) - RoasterCrawlConfig.market, CrawlUrl.available fields

Blocks: - Phase 4 (Cache/Repository) - Cache invalidation needs service methods - Phase 5 (API Integration) - API uses these services - Phase 6 (EasyAdmin) - Controllers may use services for validation

📋 Todo Checklist

  • [ ] Create AffiliateUrlService for URL transformation
  • [ ] Implement Twig template engine with sandbox security
  • [ ] Create hard-coded patterns for each affiliate provider
  • [ ] Create BuyingOptionService for URL resolution
  • [ ] Implement priority cascade logic (override → priority → affiliate)
  • [ ] Add MarketRepository::findByCountry() method
  • [ ] Create comprehensive unit tests for both services
  • [ ] Create integration tests for URL resolution flow
  • [ ] Document service usage patterns

🔍 Analysis & Investigation

Problem Statement

The multi-market system needs two core services: 1. AffiliateUrlService - Transform product URLs into affiliate tracking URLs using Twig templates 2. BuyingOptionService - Resolve which URL to show a visitor based on their country

Architecture

Service responsibilities: - AffiliateUrlService: Pure transformation (URL in → affiliate URL out) - BuyingOptionService: Business logic (visitor country → best URL with affiliate) - MarketRepository: Data access (find markets by country)

Interaction flow:

Visitor Country → BuyingOptionService
  ↓ finds markets
  ↓ finds CrawlUrls
  ↓ applies priority cascade
  ↓ calls AffiliateUrlService
Affiliate URL (or NULL)

Security Considerations

Twig templates are user input - must be sandboxed: - Restrict allowed filters (url_encode, escape only) - No functions, methods, or properties access - Strict variable mode (fail on undefined) - No tags (no control flow)

Performance Considerations

BuyingOptionService caching: - Results cached with tag-aware cache - Cache key: buying_url_{beanId}_{countryId} - TTL: 1 week (604800 seconds) - Cache tags for invalidation

Repository optimization: - Use joins to prevent N+1 queries - Fetch all needed relationships upfront

📝 Implementation Plan

Prerequisites

  • Phase 1 and 2 completed
  • Twig component installed: composer require twig
  • Tag-aware cache configured (api.cache)

Step-by-Step Implementation

Step 1: Create AffiliateUrlService

File: src/Service/Affiliate/AffiliateUrlService.php

<?php

namespace App\Service\Affiliate;

use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use Psr\Log\LoggerInterface;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityPolicy;

final readonly class AffiliateUrlService
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    /**
     * Transform a product URL into an affiliate tracking URL.
     *
     * @param string $url Original product URL
     * @param AffiliateProgram $affiliateProgram Affiliate program configuration
     * @return string Transformed URL with affiliate tracking
     */
    public function transformUrl(string $url, AffiliateProgram $affiliateProgram): string
    {
        try {
            // Get template pattern (custom or hard-coded)
            $pattern = $this->getUrlPattern($affiliateProgram);

            // Prepare template variables
            $variables = $this->prepareTemplateVariables($url, $affiliateProgram);

            // Render template
            $twig = $this->getTwigEnvironment();
            $template = $twig->createTemplate($pattern);
            $transformedUrl = $template->render($variables);

            $this->logger->debug('Affiliate URL transformed', [
                'original_url' => $url,
                'transformed_url' => $transformedUrl,
                'provider' => $affiliateProgram->getProvider()->value,
                'program_id' => $affiliateProgram->getId(),
            ]);

            return $transformedUrl;
        } catch (\Throwable $e) {
            $this->logger->error('Affiliate URL transformation failed', [
                'url' => $url,
                'program' => $affiliateProgram->getName(),
                'error' => $e->getMessage(),
            ]);

            // Fallback: return original URL
            return $url;
        }
    }

    /**
     * Get URL pattern for transformation.
     * Uses custom pattern if set, otherwise uses hard-coded pattern.
     */
    private function getUrlPattern(AffiliateProgram $affiliateProgram): string
    {
        // Custom override pattern
        if ($affiliateProgram->getUrlPattern() !== null) {
            return $affiliateProgram->getUrlPattern();
        }

        // Hard-coded patterns by provider
        return match ($affiliateProgram->getProvider()) {
            AffiliateProvider::AMAZON_ASSOCIATES => $this->getAmazonAssociatesPattern(),
            AffiliateProvider::AWIN => $this->getAwinPattern(),
            AffiliateProvider::IMPACT => $this->getImpactPattern(),
            AffiliateProvider::PARTNERIZE => $this->getPartnerizePattern(),
            AffiliateProvider::CUSTOM => throw new \LogicException(
                'CUSTOM provider requires urlPattern to be set'
            ),
        };
    }

    private function getAmazonAssociatesPattern(): string
    {
        return '{{ url }}{% if "?" in url %}&{% else %}?{% endif %}tag={{ affiliateId }}';
    }

    private function getAwinPattern(): string
    {
        // Requires parameters: awinmid
        return 'https://www.awin1.com/cread.php?awinmid={{ awinmid }}&awinaffid={{ affiliateId }}&ued={{ url|url_encode }}';
    }

    private function getImpactPattern(): string
    {
        // WARNING: This pattern is a placeholder and MUST be verified
        return 'https://tracking.impact.com/SID-{{ affiliateId }}/redirect?url={{ url|url_encode }}';
    }

    private function getPartnerizePattern(): string
    {
        // WARNING: This pattern is a placeholder and MUST be verified
        return 'https://prf.hn/click/camref:{{ affiliateId }}/destination:{{ url|url_encode }}';
    }

    /**
     * Prepare template variables.
     *
     * System variables: url, affiliateId
     * User variables: from AffiliateProgram.parameters JSON
     */
    private function prepareTemplateVariables(
        string $url,
        AffiliateProgram $affiliateProgram
    ): array {
        $variables = [
            'url' => $url,
            'affiliateId' => $affiliateProgram->getAffiliateId(),
        ];

        // Merge user-defined parameters
        if ($affiliateProgram->getParameters() !== null) {
            $variables = array_merge($variables, $affiliateProgram->getParameters());
        }

        return $variables;
    }

    /**
     * Create sandboxed Twig environment.
     *
     * Security restrictions:
     * - No tags (no control flow)
     * - Limited filters (url_encode, escape only)
     * - No functions, methods, properties
     * - Strict variables (fail on undefined)
     */
    private function getTwigEnvironment(): Environment
    {
        $loader = new ArrayLoader();
        $twig = new Environment($loader, [
            'cache' => false,
            'autoescape' => false, // We want raw URLs, not HTML-escaped
            'strict_variables' => true, // Fail on undefined variables
            'optimizations' => -1,
        ]);

        // Restrict to safe filters only
        $policy = new SecurityPolicy(
            tags: [],
            filters: ['url_encode', 'escape'],
            methods: [],
            properties: [],
            functions: []
        );

        $twig->addExtension(new SandboxExtension($policy, true));
        return $twig;
    }
}

Step 2: Extend MarketRepository

File: src/Repository/MarketRepository.php (modify existing)

Add method already implemented in Phase 1:

/**
 * Find all active markets serving a specific country.
 *
 * @return Market[]
 */
public function findByCountry(Country $country): array
{
    return $this->createQueryBuilder('m')
        ->join('m.countries', 'c')
        ->where('c.id = :countryId')
        ->andWhere('m.isActive = true')
        ->setParameter('countryId', $country->getId())
        ->getQuery()
        ->getResult();
}

Step 3: Create BuyingOptionService

File: src/Service/Market/BuyingOptionService.php

<?php

namespace App\Service\Market;

use App\Entity\CoffeeBean;
use App\Entity\Country;
use App\Repository\BuyingOptionRepository;
use App\Repository\MarketRepository;
use App\Service\Affiliate\AffiliateUrlService;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class BuyingOptionService
{
    private const CACHE_TTL = 604800; // 1 week

    public function __construct(
        private BuyingOptionRepository $buyingOptionRepository,
        private MarketRepository $marketRepository,
        private AffiliateUrlService $affiliateUrlService,
        private CacheInterface $apiCache,
        private LoggerInterface $logger
    ) {}

    /**
     * Get the buying URL for a coffee bean based on visitor's country.
     *
     * Priority cascade:
     * 1. Manual override (BuyingOption.urlOverride)
     * 2. RoasterCrawlConfig priority (highest priority wins)
     * 3. Affiliate transformation (apply market's affiliate program)
     * 4. NULL (bean not available for this country)
     *
     * @param CoffeeBean $bean Coffee bean entity (must have crawlUrls loaded with joins)
     * @param Country $visitorCountry Visitor's country
     * @return string|null Affiliate URL or NULL if not available
     */
    public function getUrlForVisitor(CoffeeBean $bean, Country $visitorCountry): ?string
    {
        $cacheKey = sprintf('buying_url_%s_%s', $bean->getId(), $visitorCountry->getId());

        return $this->apiCache->get(
            $cacheKey,
            function (ItemInterface $item) use ($bean, $visitorCountry) {
                $item->expiresAfter(self::CACHE_TTL);

                // Tag for cache invalidation
                $item->tag([
                    'buying_urls',
                    'coffee_bean_' . $bean->getId(),
                    'country_' . $visitorCountry->getId(),
                ]);

                return $this->resolveUrl($bean, $visitorCountry, $item);
            }
        );
    }

    /**
     * Check if a coffee bean is available in any market.
     *
     * @param CoffeeBean $bean Coffee bean entity
     * @return bool True if bean has any available CrawlUrl
     */
    public function isAvailableInAnyMarket(CoffeeBean $bean): bool
    {
        foreach ($bean->getCrawlUrls() as $crawlUrl) {
            if ($crawlUrl->isAvailable()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Internal URL resolution logic.
     */
    private function resolveUrl(
        CoffeeBean $bean,
        Country $visitorCountry,
        ItemInterface $cacheItem
    ): ?string {
        // 1. Find markets serving this country
        $markets = $this->marketRepository->findByCountry($visitorCountry);

        if (empty($markets)) {
            $this->logger->debug('No markets found for country', [
                'bean_id' => $bean->getId(),
                'country_id' => $visitorCountry->getId(),
            ]);
            return null;
        }

        // Tag cache with market IDs
        foreach ($markets as $market) {
            $cacheItem->tag('market_' . $market->getId());
        }

        // 2. Check for manual BuyingOption override (highest priority)
        foreach ($markets as $market) {
            $buyingOption = $this->buyingOptionRepository->findOneBy([
                'coffeeBean' => $bean,
                'market' => $market,
            ]);

            if ($buyingOption) {
                $this->logger->debug('Manual buying option found', [
                    'bean_id' => $bean->getId(),
                    'market_id' => $market->getId(),
                    'url' => $buyingOption->getUrlOverride(),
                ]);

                // Tag cache with buying option
                $cacheItem->tag(sprintf(
                    'buying_option_%s_%s',
                    $bean->getId(),
                    $market->getId()
                ));

                return $buyingOption->getUrlOverride();
            }
        }

        // 3. Find matching CrawlUrls from configs
        $matchingOptions = $this->findMatchingCrawlUrls($bean, $visitorCountry, $markets);

        if (empty($matchingOptions)) {
            $this->logger->debug('No matching crawl URLs found', [
                'bean_id' => $bean->getId(),
                'country_id' => $visitorCountry->getId(),
            ]);
            return null;
        }

        // 4. Sort by priority (highest first) and pick winner
        usort($matchingOptions, fn($a, $b) => $b['priority'] <=> $a['priority']);
        $winner = $matchingOptions[0];

        $this->logger->debug('Selected crawl URL by priority', [
            'bean_id' => $bean->getId(),
            'config_id' => $winner['config']->getId(),
            'priority' => $winner['priority'],
            'url' => $winner['crawlUrl']->getUrl(),
        ]);

        // 5. Apply affiliate transformation if available
        if ($winner['market'] !== null) {
            $affiliateProgram = $winner['market']->getAffiliateProgram();
            if ($affiliateProgram && $affiliateProgram->isActive()) {
                $cacheItem->tag('affiliate_program_' . $affiliateProgram->getId());

                return $this->affiliateUrlService->transformUrl(
                    $winner['crawlUrl']->getUrl(),
                    $affiliateProgram
                );
            }
        }

        // 6. Fallback to direct URL (no affiliate or legacy mode)
        return $winner['crawlUrl']->getUrl();
    }

    /**
     * Find all CrawlUrls that match visitor's country.
     *
     * Checks:
     * 1. CrawlUrl is available
     * 2. RoasterCrawlConfig is active
     * 3. Roaster ships to visitor's country (logistics check)
     * 4. Market serves visitor's country (monetization check, if market exists)
     */
    private function findMatchingCrawlUrls(
        CoffeeBean $bean,
        Country $visitorCountry,
        array $markets
    ): array {
        $marketIds = array_map(fn($m) => $m->getId(), $markets);
        $matchingOptions = [];

        foreach ($bean->getCrawlUrls() as $crawlUrl) {
            if (!$crawlUrl->isAvailable()) {
                continue; // Skip unavailable URLs
            }

            $config = $crawlUrl->getRoasterCrawlConfig();
            if (!$config->isActive()) {
                continue;
            }

            // Check 1: Can roaster ship to this country? (logistics)
            if (!$this->configShipsToCountry($config, $visitorCountry->getId())) {
                continue;
            }

            $configMarket = $config->getMarket();

            if ($configMarket !== null) {
                // Check 2: Does market serve this country? (monetization)
                if (!in_array($configMarket->getId(), $marketIds, true)) {
                    continue; // Market doesn't serve this country
                }

                // Both checks passed
                $matchingOptions[] = [
                    'crawlUrl' => $crawlUrl,
                    'config' => $config,
                    'market' => $configMarket,
                    'priority' => $config->getPriority(),
                ];
            } else {
                // Legacy mode: no market = no affiliate transformation
                $matchingOptions[] = [
                    'crawlUrl' => $crawlUrl,
                    'config' => $config,
                    'market' => null,
                    'priority' => $config->getPriority(),
                ];
            }
        }

        return $matchingOptions;
    }

    /**
     * Check if a RoasterCrawlConfig ships to a specific country.
     */
    private function configShipsToCountry($config, string $countryId): bool
    {
        // No restrictions = ships everywhere
        if ($config->getShipsTo()->isEmpty()) {
            return true;
        }

        // Check if country is in shipsTo collection
        foreach ($config->getShipsTo() as $country) {
            if ($country->getId() === $countryId) {
                return true;
            }
        }

        return false;
    }
}

Step 4: Create Service Tests

File: tests/Service/Affiliate/AffiliateUrlServiceTest.php

<?php

namespace App\Tests\Service\Affiliate;

use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use App\Service\Affiliate\AffiliateUrlService;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

class AffiliateUrlServiceTest extends TestCase
{
    private AffiliateUrlService $service;

    protected function setUp(): void
    {
        $this->service = new AffiliateUrlService(new NullLogger());
    }

    public function testAmazonAssociatesTransformation(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
        $program->setAffiliateId('beanb-20');

        $url = 'https://example.com/product/123';
        $result = $this->service->transformUrl($url, $program);

        $this->assertEquals('https://example.com/product/123?tag=beanb-20', $result);
    }

    public function testAmazonAssociatesWithExistingQueryParams(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
        $program->setAffiliateId('beanb-20');

        $url = 'https://example.com/product/123?color=blue';
        $result = $this->service->transformUrl($url, $program);

        $this->assertEquals('https://example.com/product/123?color=blue&tag=beanb-20', $result);
    }

    public function testAwinTransformation(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::AWIN);
        $program->setAffiliateId('67890');
        $program->setParameters(['awinmid' => '12345']);

        $url = 'https://example.com/product/123';
        $result = $this->service->transformUrl($url, $program);

        $expected = 'https://www.awin1.com/cread.php?awinmid=12345&awinaffid=67890&ued=https%3A%2F%2Fexample.com%2Fproduct%2F123';
        $this->assertEquals($expected, $result);
    }

    public function testCustomPatternTransformation(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::CUSTOM);
        $program->setAffiliateId('partner-id');
        $program->setUrlPattern('{{ url }}{% if "?" in url %}&{% else %}?{% endif %}ref={{ affiliateId }}&utm_source={{ source }}');
        $program->setParameters(['source' => 'bean-business']);

        $url = 'https://example.com/product/123';
        $result = $this->service->transformUrl($url, $program);

        $this->assertEquals('https://example.com/product/123?ref=partner-id&utm_source=bean-business', $result);
    }

    public function testFallbackToOriginalUrlOnError(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::CUSTOM);
        $program->setAffiliateId('partner-id');
        // Invalid template (missing variable)
        $program->setUrlPattern('{{ url }}?ref={{ missing_variable }}');

        $url = 'https://example.com/product/123';
        $result = $this->service->transformUrl($url, $program);

        // Should fallback to original URL
        $this->assertEquals($url, $result);
    }

    public function testUrlPatternOverridesHardCodedPattern(): void
    {
        $program = new AffiliateProgram();
        $program->setProvider(AffiliateProvider::AMAZON_ASSOCIATES);
        $program->setAffiliateId('beanb-20');
        // Override hard-coded pattern
        $program->setUrlPattern('{{ url }}?tag={{ affiliateId }}&ref=custom');

        $url = 'https://example.com/product/123';
        $result = $this->service->transformUrl($url, $program);

        $this->assertEquals('https://example.com/product/123?tag=beanb-20&ref=custom', $result);
    }
}

File: tests/Service/Market/BuyingOptionServiceTest.php

Test priority cascade, manual overrides, availability checks, caching behavior.

Step 5: Create Integration Tests

File: tests/Integration/MultiMarketUrlResolutionTest.php

Test full flow: Visitor country → URL resolution → Affiliate transformation

Testing Strategy

Unit Tests

  1. AffiliateUrlService:
  2. Test each provider's hard-coded pattern
  3. Test custom pattern rendering
  4. Test parameter merging
  5. Test error handling (fallback to original URL)
  6. Test URL pattern override

  7. BuyingOptionService:

  8. Test manual override priority
  9. Test RCC priority sorting
  10. Test legacy mode (market = NULL)
  11. Test availability filtering
  12. Test shipping restrictions
  13. Test market country filtering
  14. Test cache tagging

  15. MarketRepository:

  16. Test findByCountry() with active/inactive markets
  17. Test with multiple markets serving same country

Integration Tests

  1. Full URL Resolution Flow:
  2. Create test data (markets, configs, beans, URLs)
  3. Test visitor from different countries
  4. Verify correct URL selected
  5. Verify affiliate transformation applied
  6. Test priority cascade

  7. Cache Invalidation:

  8. Test cache tags are set correctly
  9. Verify cache invalidates when entities change

🎯 Success Criteria

  • AffiliateUrlService transforms URLs correctly for all providers
  • Twig sandbox prevents code injection
  • BuyingOptionService implements correct priority cascade:
  • Manual override (BuyingOption)
  • RCC priority (highest wins)
  • Affiliate transformation
  • NULL if not available
  • Cache works correctly with proper tags
  • Legacy mode (market = NULL) works
  • All unit tests pass
  • Integration tests verify full flow
  • Performance acceptable (caching reduces database queries)
  • Error handling graceful (fallback to original URL)

Files to Create:

  • src/Service/Affiliate/AffiliateUrlService.php
  • src/Service/Market/BuyingOptionService.php
  • tests/Service/Affiliate/AffiliateUrlServiceTest.php
  • tests/Service/Market/BuyingOptionServiceTest.php
  • tests/Integration/MultiMarketUrlResolutionTest.php

Files to Modify:

  • src/Repository/MarketRepository.php (add findByCountry if not already present)

Files Referenced:

  • src/Entity/Market.php
  • src/Entity/AffiliateProgram.php
  • src/Entity/BuyingOption.php
  • src/Entity/RoasterCrawlConfig.php
  • src/Entity/CrawlUrl.php
  • src/Entity/Country.php

Next Steps

After completing this phase: - Proceed to Phase 4 (Cache/Repository) to extend cache invalidation and repository methods - Services ready for API integration in Phase 5 - Services available for EasyAdmin validation in Phase 6