Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 108 |
|
0.00% |
0 / 2 |
CRAP | |
0.00% |
0 / 1 |
| GoogleAdsWebhooks | |
0.00% |
0 / 108 |
|
0.00% |
0 / 2 |
600 | |
0.00% |
0 / 1 |
| receive | |
0.00% |
0 / 70 |
|
0.00% |
0 / 1 |
90 | |||
| parseUserColumnData | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
240 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Http\Controllers\Quotations; |
| 6 | use App\Models\TblGoogleAdsForms; |
| 7 | use App\Models\TblGoogleAdsLeadsLog; |
| 8 | use App\Models\TblQuotations; |
| 9 | use Illuminate\Http\Request; |
| 10 | use Illuminate\Support\Carbon; |
| 11 | use 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 | */ |
| 34 | class 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 | } |