Skip to content

Commit e27d512

Browse files
committed
Upload customer lists to Google Ads
1 parent 93269c6 commit e27d512

File tree

88 files changed

+2331
-45
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+2331
-45
lines changed

.claude/settings.local.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(find:*)",
5+
"Bash(rg:*)"
6+
],
7+
"deny": []
8+
}
9+
}

CLAUDE.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is a Sylius plugin that integrates Google Ads conversion tracking with Sylius e-commerce stores. The plugin uses the Google Ads API directly (server-side) instead of JavaScript tracking, providing better control over consent, conversion values, and avoiding ad blockers.
8+
9+
## Development Commands
10+
11+
### Testing
12+
```bash
13+
# Run all tests
14+
./vendor/bin/phpunit
15+
16+
# Run specific test
17+
./vendor/bin/phpunit tests/Resolver/CustomerIdsResolverTest.php
18+
```
19+
20+
### Code Quality
21+
```bash
22+
# Code style checking
23+
composer check-style
24+
25+
# Fix code style issues
26+
composer fix-style
27+
28+
# Static analysis
29+
composer analyse
30+
```
31+
32+
### Database Operations
33+
```bash
34+
# Create migration after model changes
35+
php bin/console doctrine:migrations:diff
36+
37+
# Run migrations
38+
php bin/console doctrine:migrations:migrate
39+
```
40+
41+
### Plugin Console Commands
42+
```bash
43+
# Process Google Ads conversions (run regularly, e.g., every 5 minutes)
44+
php bin/console setono:sylius-google-ads:process-conversions
45+
46+
# Prune old conversions from database (run daily)
47+
php bin/console setono:sylius-google-ads:prune-conversions
48+
```
49+
50+
## Architecture
51+
52+
### Core Components
53+
54+
**Models**: Connection, Conversion, CustomerList, ConnectionMapping, MerchantMapping
55+
- Connection: Stores Google Ads API credentials and configuration
56+
- Conversion: Represents tracked conversions with state workflow
57+
- CustomerList: Manages Google Ads customer lists for remarketing
58+
59+
**Processing Flow**:
60+
1. **Event Capture**: PurchaseListener captures order events
61+
2. **Conversion Creation**: ConversionFactory creates conversion records
62+
3. **Qualification**: QualificationVoters determine if conversion should be processed
63+
4. **Processing**: ConversionProcessor handles API calls to Google Ads
64+
5. **State Management**: ConversionWorkflow manages conversion states
65+
66+
**Message System**: Uses Symfony Messenger for async processing
67+
- ProcessConversion: Handles individual conversion processing
68+
- ProcessCustomerList/UploadCustomerList: Manages customer list operations
69+
70+
### Key Directories
71+
72+
- `src/Model/`: Core domain models and interfaces
73+
- `src/ConversionProcessor/`: Conversion processing logic with qualification voters
74+
- `src/Message/`: Symfony Messenger commands and handlers
75+
- `src/Client/`: Google Ads API client wrapper and resources
76+
- `src/EventSubscriber/`: Event-driven processing subscribers
77+
- `src/Factory/`: Object creation factories
78+
- `src/Repository/`: Data access layer
79+
- `src/Resources/config/`: Service definitions and configuration
80+
81+
### Google Ads Integration
82+
83+
The plugin integrates with Google Ads through:
84+
- **OAuth2 Flow**: Managed via Controller/Action classes for setup
85+
- **API Client**: Wraps Google Ads PHP library with custom resource classes
86+
- **Conversion Tracking**: Server-side conversion uploads with user identifiers
87+
- **Customer Lists**: Audience management for remarketing campaigns
88+
89+
### State Management
90+
91+
Conversions follow a workflow with states:
92+
- Created → Processing → Delivered/Failed
93+
- Managed by ConversionWorkflow class
94+
- Supports retry logic for failed conversions
95+
96+
## Configuration
97+
98+
Plugin configuration is in `src/Resources/config/app/config.yaml` and requires:
99+
- Google Ads API credentials (client ID, secret, developer token)
100+
- Customer ID mapping for multi-account setups
101+
- Conversion action mapping for different event types
102+
103+
## Testing
104+
105+
The plugin includes both unit tests and integration tests. Live testing can be enabled by setting `GOOGLE_ADS_LIVE=1` and providing real API credentials in phpunit.xml.
106+
107+
## Important Notes
108+
109+
- Customer Lists feature is marked as experimental
110+
- Requires gRPC PHP extension for optimal performance
111+
- Uses Doctrine ORM for persistence
112+
- Integrates with Sylius workflow and event systems
113+
- Supports multi-channel and multi-customer configurations

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ Now the plugin is installed. Please read the next section to learn how to use it
9797

9898
To start using the plugin, go to https://your-domain.com/admin/google-ads and follow the instructions.
9999

100+
# Backward compatibility
101+
102+
The plugin adheres to semantic versioning, however, if a feature is `experimental` it is not included in the promise.
103+
104+
Right now the customer lists feature is experimental.
105+
100106
[ico-version]: https://poser.pugx.org/setono/sylius-google-ads-plugin/v/stable
101107
[ico-license]: https://poser.pugx.org/setono/sylius-google-ads-plugin/license
102108
[ico-github-actions]: https://github.com/Setono/SyliusGoogleAdsPlugin/workflows/build/badge.svg

composer.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@
1414
"doctrine/dbal": "^2.13 || ^3.8",
1515
"doctrine/orm": "^2.7",
1616
"doctrine/persistence": "^1.3 || ^2.2 || ^3.2",
17-
"google/auth": "^1.26",
18-
"google/gax": "^1.19",
17+
"google/auth": "^1.44",
18+
"google/gax": "^1.35.1",
1919
"googleads/google-ads-php": "^27.2",
2020
"knplabs/knp-menu": "^3.1",
21-
"ocramius/doctrine-batch-utils": "^2.4",
21+
"ocramius/doctrine-batch-utils": "^2.6",
2222
"psr/event-dispatcher": "^1.0",
2323
"psr/log": "^1.0 || ^2.0 || ^3.0",
24-
"setono/composite-compiler-pass": "^1.1",
25-
"setono/doctrine-orm-trait": "^1.0",
24+
"setono/composite-compiler-pass": "^1.2",
25+
"setono/doctrine-orm-trait": "^1.2",
2626
"sylius/channel": "^1.0",
2727
"sylius/channel-bundle": "^1.0",
2828
"sylius/core": "^1.0",
2929
"sylius/core-bundle": "^1.0",
30+
"sylius/customer": "^1.0",
3031
"sylius/order": "^1.0",
3132
"sylius/resource-bundle": "^1.6",
3233
"sylius/ui-bundle": "^1.0",

psalm.xml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
44
xmlns="https://getpsalm.org/schema/config"
55
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
6-
phpVersion="8.1"
7-
errorLevel="1"
86
findUnusedBaselineEntry="false"
97
findUnusedPsalmSuppress="false"
108
findUnusedCode="false"
119
findUnusedVariablesAndParams="false"
10+
phpVersion="8.1"
11+
errorLevel="1"
1212
>
1313
<projectFiles>
1414
<directory name="src"/>
1515
<directory name="tests"/>
1616
<ignoreFiles>
17+
<file name="src/Message/Handler/UploadCustomersHandler.php"/>
1718
<directory name="tests/Application"/>
1819
<directory name="vendor"/>
1920
</ignoreFiles>
@@ -24,7 +25,6 @@
2425
</plugins>
2526
<issueHandlers>
2627
<PluginIssue name="QueryBuilderSetParameter" errorLevel="info"/>
27-
<UnnecessaryVarAnnotation errorLevel="suppress"/> <!-- We use unnecessary var annotations to aid the IDE -->
2828
<TooManyTemplateParams>
2929
<errorLevel type="suppress">
3030
<directory name="src/Factory"/> <!-- Sylius added @template annotations in later editions -->
@@ -33,4 +33,12 @@
3333
</TooManyTemplateParams>
3434
<RedundantConditionGivenDocblockType errorLevel="suppress"/>
3535
</issueHandlers>
36+
<forbiddenFunctions>
37+
<function name="dd"/>
38+
<function name="dump"/>
39+
<function name="echo"/>
40+
<function name="exit"/>
41+
<function name="var_dump"/>
42+
<function name="print_r"/>
43+
</forbiddenFunctions>
3644
</psalm>

src/Client/Client.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusGoogleAdsPlugin\Client;
6+
7+
use Google\Ads\GoogleAds\Lib\V19\GoogleAdsClient;
8+
use Google\Ads\GoogleAds\V19\Services\GetGoogleAdsFieldRequest;
9+
use Google\Ads\GoogleAds\V19\Services\SearchGoogleAdsStreamRequest;
10+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\OfflineUserDataJobsResource;
11+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\OfflineUserDataJobsResourceInterface;
12+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\ResourceMetadata;
13+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListCustomerTypesResource;
14+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListCustomerTypesResourceInterface;
15+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListsResource;
16+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListsResourceInterface;
17+
18+
final class Client implements ClientInterface
19+
{
20+
private ?OfflineUserDataJobsResourceInterface $offlineUserDataJobsResource = null;
21+
22+
private ?UserListsResourceInterface $userListsResource = null;
23+
24+
private ?UserListCustomerTypesResourceInterface $userListCustomerTypesResource = null;
25+
26+
public function __construct(
27+
private readonly GoogleAdsClient $googleAdsClient,
28+
private readonly string $customerId,
29+
) {
30+
}
31+
32+
public function offlineUserDataJob(): OfflineUserDataJobsResourceInterface
33+
{
34+
if (null === $this->offlineUserDataJobsResource) {
35+
$this->offlineUserDataJobsResource = new OfflineUserDataJobsResource($this, $this->googleAdsClient);
36+
}
37+
38+
return $this->offlineUserDataJobsResource;
39+
}
40+
41+
public function userLists(): UserListsResourceInterface
42+
{
43+
if (null === $this->userListsResource) {
44+
$this->userListsResource = new UserListsResource($this, $this->googleAdsClient);
45+
}
46+
47+
return $this->userListsResource;
48+
}
49+
50+
public function userListCustomerTypes(): UserListCustomerTypesResourceInterface
51+
{
52+
if (null === $this->userListCustomerTypesResource) {
53+
$this->userListCustomerTypesResource = new UserListCustomerTypesResource($this, $this->googleAdsClient);
54+
}
55+
56+
return $this->userListCustomerTypesResource;
57+
}
58+
59+
public function search(string $query): SearchResponse
60+
{
61+
return SearchResponse::fromServerStream($this->googleAdsClient->getGoogleAdsServiceClient()->searchStream(
62+
SearchGoogleAdsStreamRequest::build($this->customerId, $query),
63+
));
64+
}
65+
66+
public function getResourceMetadata(string $resourceName): ResourceMetadata
67+
{
68+
if (!str_contains($resourceName, '/')) {
69+
$resourceName = 'googleAdsFields/' . $resourceName;
70+
}
71+
72+
$field = $this->googleAdsClient->getGoogleAdsFieldServiceClient()->getGoogleAdsField(
73+
GetGoogleAdsFieldRequest::build($resourceName),
74+
);
75+
76+
return ResourceMetadata::fromGoogleAdsField($field);
77+
}
78+
79+
public function getCustomerId(): string
80+
{
81+
return $this->customerId;
82+
}
83+
}

src/Client/ClientInterface.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusGoogleAdsPlugin\Client;
6+
7+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListCustomerTypesResourceInterface;
8+
use Setono\SyliusGoogleAdsPlugin\Client\Resource\UserListsResourceInterface;
9+
10+
interface ClientInterface
11+
{
12+
public function userLists(): UserListsResourceInterface;
13+
14+
public function userListCustomerTypes(): UserListCustomerTypesResourceInterface;
15+
16+
public function search(string $query): SearchResponse;
17+
18+
public function getCustomerId(): string;
19+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusGoogleAdsPlugin\Client\Resource;
6+
7+
use Google\Ads\GoogleAds\Lib\V19\GoogleAdsClient;
8+
use Google\Protobuf\Internal\Message;
9+
use Setono\SyliusGoogleAdsPlugin\Client\ClientInterface;
10+
use Webmozart\Assert\Assert;
11+
12+
/**
13+
* @template T of Message
14+
*/
15+
abstract class AbstractResource
16+
{
17+
public function __construct(
18+
protected readonly ClientInterface $client,
19+
protected readonly GoogleAdsClient $googleAdsClient,
20+
) {
21+
}
22+
23+
public function createOrUpdate(?string $resourceName, callable $update, callable $create): string
24+
{
25+
if (!$this instanceof ReadableResourceInterface) {
26+
throw new \RuntimeException(sprintf('The resource must implement %s', ReadableResourceInterface::class));
27+
}
28+
29+
if (!$this instanceof UpdatableResourceInterface) {
30+
throw new \RuntimeException(sprintf('The resource must implement %s', UpdatableResourceInterface::class));
31+
}
32+
33+
$obj = null;
34+
35+
if (null !== $resourceName) {
36+
$obj = $this->get($resourceName);
37+
}
38+
39+
if (null === $obj) {
40+
/** @psalm-suppress UnsafeInstantiation */
41+
$obj = new (self::getResourceClass());
42+
43+
$create($obj);
44+
$update($obj);
45+
46+
$resourceName = $this->create($obj);
47+
} else {
48+
$update($obj);
49+
$resourceName = $this->update($obj);
50+
}
51+
52+
return $resourceName;
53+
}
54+
55+
/**
56+
* @return class-string<T>
57+
*/
58+
abstract protected static function getResourceClass(): string;
59+
60+
protected function handleMutateResponse(object $response): string
61+
{
62+
if (!method_exists($response, 'getResults')) {
63+
throw new \RuntimeException('The response does not have a results method');
64+
}
65+
66+
/** @var mixed $results */
67+
$results = $response->getResults();
68+
Assert::isInstanceOf($results, \Traversable::class);
69+
70+
$results = iterator_to_array($results, false);
71+
Assert::count($results, 1);
72+
73+
$result = $results[0];
74+
Assert::object($result);
75+
if (!method_exists($result, 'getResourceName')) {
76+
throw new \RuntimeException('The result does not have a resource name method');
77+
}
78+
79+
/** @var mixed $resourceName */
80+
$resourceName = $result->getResourceName();
81+
Assert::string($resourceName);
82+
83+
return $resourceName;
84+
}
85+
}

0 commit comments

Comments
 (0)