Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
GoogleAdsWebhooks
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 2
600
0.00% covered (danger)
0.00%
0 / 1
 receive
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
90
 parseUserColumnData
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Http\Controllers\Quotations;
6use App\Models\TblGoogleAdsForms;
7use App\Models\TblGoogleAdsLeadsLog;
8use App\Models\TblQuotations;
9use Illuminate\Http\Request;
10use Illuminate\Support\Carbon;
11use Illuminate\Support\Facades\Log;
12
13/**
14 * FIRE-XXXX — receives Google Ads Lead Form Extensions webhook POSTs.
15 *
16 * Route: POST /api/webhooks/google-ads-leads/{form_key}
17 *
18 * Auth: Google can't bearer-auth, so the route is OUTSIDE the auth.token
19 * middleware group. Instead we verify the `google_key` field in the body
20 * against the per-form stored secret (hash_equals to avoid timing leaks).
21 *
22 * Behaviour:
23 *   - Always writes the raw payload to tbl_google_ads_leads_log first, so
24 *     forensics is available even when the webhook fails auth or errors.
25 *   - is_test=true → log only, no quotation. Lets Nicolás verify wiring.
26 *   - Real lead → creates a tbl_quotations row with the form's default
27 *     company_id / commercial / source_id, status_id=13 (Solicitud), and
28 *     last_follow_up_comment populated from FULL_NAME / EMAIL / PHONE
29 *     plus any custom fields (e.g. M2 range).
30 *   - Duplicates by email/phone are NOT merged — every webhook hit creates
31 *     a new quotation, so the comercial sees both and dedupes manually
32 *     (clean audit trail; per Nicolás).
33 */
34class GoogleAdsWebhooks extends Controller
35{
36    /**
37     * Standard Google Ads `column_id`s we recognise. Anything outside this
38     * set is treated as a custom field and appended to last_follow_up_comment.
39     */
40    private const STANDARD_COLUMN_IDS = [
41        'FULL_NAME', 'FIRST_NAME', 'LAST_NAME',
42        'EMAIL', 'PHONE_NUMBER',
43        'COMPANY_NAME',
44    ];
45
46    public function receive(Request $request, string $formKey)
47    {
48        $payload = $request->all();
49        $isTest = (bool) ($payload['is_test'] ?? false);
50
51        // Always log first — even auth failures land here for forensics.
52        $logRow = TblGoogleAdsLeadsLog::create([
53            'form_key' => $formKey,
54            'raw_payload' => $payload,
55            'is_test' => $isTest ? 1 : 0,
56            'quotation_id' => null,
57            'error_message' => null,
58            'created_at' => Carbon::now(),
59        ]);
60
61        try {
62            $form = TblGoogleAdsForms::where('form_key', $formKey)
63                ->where('active', 1)
64                ->first();
65
66            if (! $form) {
67                $logRow->update(['error_message' => 'form_key not found or inactive']);
68
69                return response(['message' => 'KO', 'error' => 'invalid form_key'], 404);
70            }
71
72            $providedKey = (string) ($payload['google_key'] ?? '');
73            if ($providedKey === '' || ! hash_equals($form->google_key, $providedKey)) {
74                $logRow->update(['error_message' => 'google_key mismatch']);
75
76                return response(['message' => 'KO', 'error' => 'invalid google_key'], 401);
77            }
78
79            // Test leads land in the log only — Nicolás can verify the
80            // wiring from Google's panel without polluting tbl_quotations.
81            if ($isTest) {
82                Log::channel('third-party')->info(
83                    "Google Ads webhook test lead received for form '{$formKey}' — not creating quotation"
84                );
85
86                return response(['message' => 'OK', 'note' => 'test lead, no quotation created'], 200);
87            }
88
89            $parsed = $this->parseUserColumnData($payload['user_column_data'] ?? []);
90
91            $commentParts = ['Lead Google Ads recibido '.Carbon::now()->format('d/m/Y H:i')];
92            foreach ($parsed['custom_fields'] as $label => $value) {
93                $commentParts[] = "{$label}{$value}";
94            }
95            $comment = implode(' | ', $commentParts);
96
97            // Mirror create_quotation: allocate the next quote_id ("# en Titan")
98            // via Quotations::get_number(), which inserts a placeholder row
99            // with for_add=1 and returns its id + the new number. We then
100            // UPDATE that placeholder with our real fields. Without this the
101            // quote_id stayed NULL and the Pedidos listing couldn't render
102            // the row properly.
103            $numberRequest = new Request(['created_by' => 'Google Ads']);
104            $numberResponse = app(Quotations::class)->get_number($numberRequest, $form->company_id);
105            $placeholderId = $numberResponse->original['id'];
106            $newNumber = $numberResponse->original['number'];
107
108            // request_date / issue_date: Google's webhook doesn't carry a
109            // user-submission timestamp, but it fires within seconds of the
110            // user pressing submit, so "now" is accurate. Mirrors G3W sync,
111            // which sets both fields to `fecha_creacion`.
112            $now = Carbon::now();
113
114            TblQuotations::where('id', $placeholderId)->update([
115                'quote_id' => $newNumber,
116                'client' => $parsed['client'],
117                'email' => $parsed['email'],
118                'phone_number' => $parsed['phone_number'],
119                'budget_status_id' => $form->status_id ?: 6,   // 6 = Solicitud
120                'source_id' => $form->default_source_id,
121                'commercial' => $form->default_commercial,
122                'customer_type_id' => 2,
123                'created_by' => 'Google Ads',
124                'request_date' => $now,
125                'issue_date' => $now,
126                'updated_at' => $now,
127                'last_follow_up_comment' => $comment,
128                'has_attachment' => 0,
129                'sync_import' => 0,
130                'for_add' => 0,
131                'for_approval' => 0,
132            ]);
133            $quotation = TblQuotations::find($placeholderId);
134
135            $logRow->update(['quotation_id' => $quotation->id]);
136
137            Log::channel('third-party')->info(
138                "Google Ads webhook → quotation #{$quotation->id} created for form '{$formKey}'"
139            );
140
141            return response([
142                'message' => 'OK',
143                'quotation_id' => $quotation->id,
144            ], 200);
145        } catch (\Throwable $e) {
146            $logRow->update(['error_message' => substr($e->getMessage(), 0, 1000)]);
147
148            Log::channel('third-party')->error(
149                "Google Ads webhook error for form '{$formKey}': ".$e->getMessage(),
150                ['trace' => $e->getTraceAsString()]
151            );
152
153            return response(['message' => 'KO', 'error' => 'internal'], 500);
154        }
155    }
156
157    /**
158     * Walk Google's user_column_data[] and map standard column_ids to
159     * named fields. Unknown column_ids land in custom_fields keyed by
160     * the friendly column_name (or column_id if column_name is empty).
161     *
162     * @param  array<int, array{column_id?: string, column_name?: string, string_value?: string}>  $userData
163     * @return array{client: ?string, email: ?string, phone_number: ?string, custom_fields: array<string, string>}
164     */
165    private function parseUserColumnData(array $userData): array
166    {
167        $result = [
168            'client' => null,
169            'email' => null,
170            'phone_number' => null,
171            'custom_fields' => [],
172        ];
173
174        $firstName = null;
175        $lastName = null;
176
177        foreach ($userData as $field) {
178            $id = strtoupper((string) ($field['column_id'] ?? ''));
179            $name = (string) ($field['column_name'] ?? '');
180            $value = trim((string) ($field['string_value'] ?? ''));
181
182            if ($value === '') {
183                continue;
184            }
185
186            switch ($id) {
187                case 'FULL_NAME':
188                    $result['client'] = $value;
189                    break;
190                case 'FIRST_NAME':
191                    $firstName = $value;
192                    break;
193                case 'LAST_NAME':
194                    $lastName = $value;
195                    break;
196                case 'EMAIL':
197                    $result['email'] = $value;
198                    break;
199                case 'PHONE_NUMBER':
200                    $result['phone_number'] = $value;
201                    break;
202                case 'COMPANY_NAME':
203                    // Only fill client from COMPANY_NAME if we don't have
204                    // a FULL_NAME already (B2C forms might send only one).
205                    if ($result['client'] === null) {
206                        $result['client'] = $value;
207                    }
208                    break;
209                default:
210                    // Custom field — stash for the comment.
211                    $label = $name !== '' ? $name : $id;
212                    $result['custom_fields'][$label] = $value;
213            }
214        }
215
216        // FIRST_NAME + LAST_NAME → client when FULL_NAME wasn't sent.
217        if ($result['client'] === null && ($firstName !== null || $lastName !== null)) {
218            $result['client'] = trim(($firstName ?? '').' '.($lastName ?? ''));
219        }
220
221        return $result;
222    }
223}