time() + 3650 * 86400, // ~10 years (browser may clamp to ~400 days) 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); } } if ($ref === '' && !empty($_COOKIE[ABCPRO_REF_COOKIE])) { $ref = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_COOKIE[ABCPRO_REF_COOKIE])); } return $ref; } /** The current ref code without touching cookies (read-only). */ function abcpro_current_ref(): string { if (!empty($_COOKIE[ABCPRO_REF_COOKIE])) { return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_COOKIE[ABCPRO_REF_COOKIE])); } return !empty($_GET['ref']) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_GET['ref'])) : ''; } /** * Report a sale (or refund/chargeback) to the hub. Non-blocking philosophy: * on any failure the payload is appended to a local queue for later retry and * the function returns false — it never throws into the checkout flow. * * @param array $sale { * external_id (required), gross_cents, base_cents?, currency?, * buyer_email, buyer_name?, buyer_external_id?, ref_code?, occurred_at?, * clears_at?, meta?, overrides?, event? ('sale'|'refund'|'chargeback') * } * @return bool true if the hub accepted it (HTTP 2xx) */ function abcpro_report_sale(array $sale): bool { $hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/'); $keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', ''); $secret = (string)abcpro_cfg('ABCPRO_SECRET', ''); if ($hub === '' || $keyId === '' || $secret === '') { abcpro_queue($sale, 'unconfigured'); return false; } // Default the ref code to the captured cookie if the caller didn't pass one. if (empty($sale['ref_code'])) { $r = abcpro_current_ref(); if ($r !== '') $sale['ref_code'] = $r; } if (empty($sale['project_key'])) $sale['project_key'] = abcpro_cfg('ABCPRO_PROJECT_KEY', ''); // Stamp a monotonic report SEQUENCE number the FIRST time this report is built, // so the hub can detect a dropped report as a gap. It rides through the retry // queue unchanged, so a queued+flushed report keeps its original number. // A project with its own DB can pass $sale['seq'] itself (a stable row id); // otherwise we use a durable local counter. if (!isset($sale['seq'])) $sale['seq'] = abcpro_next_seq(); $body = json_encode($sale, JSON_UNESCAPED_SLASHES); $ts = time(); $sig = hash_hmac('sha256', $ts . '.' . $body, $secret); $ch = curl_init($hub . '/api/v1/sale.php'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 6, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-AbcPro-Key: ' . $keyId, 'X-AbcPro-Timestamp: ' . $ts, 'X-AbcPro-Signature: ' . $sig, ], ]); $resp = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 200 && $code < 300) return true; // Hub unreachable or rejected → queue for retry, never break checkout. abcpro_queue($sale, 'http_' . $code . ($resp ? ':' . substr((string)$resp, 0, 200) : '')); return false; } /** Convenience: report a refund/chargeback by the product's external id. */ function abcpro_report_refund(string $externalId, string $type = 'refund'): bool { return abcpro_report_sale(['event' => $type, 'external_id' => $externalId]); } /** * Durable, monotonic report counter for this project. Atomic under concurrency * via an exclusive file lock. Stored next to the queue file so it survives * restarts. (Multi-server products should instead pass their own stable seq.) */ function abcpro_next_seq(): int { $qf = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl'); $file = (string)abcpro_cfg('ABCPRO_SEQ_FILE', preg_replace('/\.jsonl$/', '', $qf) . '.seq'); $fh = @fopen($file, 'c+'); if (!$fh) return (int)(microtime(true) * 1000); // last-resort: time-based, still monotonic-ish @flock($fh, LOCK_EX); $cur = (int)stream_get_contents($fh); $next = $cur + 1; rewind($fh); ftruncate($fh, 0); fwrite($fh, (string)$next); fflush($fh); @flock($fh, LOCK_UN); fclose($fh); return $next; } /** * Send a signed MANIFEST — the project's own independent tally — so the hub can * cross-check it against what it received (gap detection + total match). Call * from the product's cron (e.g. hourly). The project computes these from its OWN * database: * seq_high the highest report number it has sent (abcpro current seq) * report_count how many reports it has sent in total * gross_total_cents (optional) the project's own gross total for those reports * * @return bool true on HTTP 2xx */ function abcpro_report_manifest(int $seqHigh, int $reportCount, ?int $grossTotalCents = null): bool { $hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/'); $keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', ''); $secret = (string)abcpro_cfg('ABCPRO_SECRET', ''); if ($hub === '' || $keyId === '' || $secret === '') return false; $payload = ['seq_high' => $seqHigh, 'report_count' => $reportCount, 'as_of' => date('c')]; if ($grossTotalCents !== null) $payload['gross_total_cents'] = $grossTotalCents; $body = json_encode($payload, JSON_UNESCAPED_SLASHES); $ts = time(); $sig = hash_hmac('sha256', $ts . '.' . $body, $secret); $ch = curl_init($hub . '/api/v1/manifest.php'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-AbcPro-Key: ' . $keyId, 'X-AbcPro-Timestamp: ' . $ts, 'X-AbcPro-Signature: ' . $sig, ], ]); curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $code >= 200 && $code < 300; } /** The current high-water report number (without incrementing). For manifests. */ function abcpro_current_seq(): int { $qf = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl'); $file = (string)abcpro_cfg('ABCPRO_SEQ_FILE', preg_replace('/\.jsonl$/', '', $qf) . '.seq'); return is_readable($file) ? (int)file_get_contents($file) : 0; } /** * Push this product's catalog to AbcPro so its offerings appear in every Growth * Consultant's storefront. Call it whenever items change, or from a cron. * * Each item: [ 'external_item_id'=>'event:501', 'title'=>'…', 'buy_url'=>'https://…', * 'price_cents'=>2500, 'description'=>'…', 'image_url'=>'…', 'category'=>'…', 'status'=>'active' ] * (external_item_id, title, buy_url are required.) * * @param bool $replaceAll if true, AbcPro deactivates any of this product's items not in $items. * @return bool true on HTTP 2xx */ function abcpro_push_catalog(array $items, bool $replaceAll = false): bool { $hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/'); $keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', ''); $secret = (string)abcpro_cfg('ABCPRO_SECRET', ''); if ($hub === '' || $keyId === '' || $secret === '') return false; $body = json_encode(['items' => array_values($items), 'replace_all' => $replaceAll], JSON_UNESCAPED_SLASHES); $ts = time(); $sig = hash_hmac('sha256', $ts . '.' . $body, $secret); $ch = curl_init($hub . '/api/v1/catalog.php'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-AbcPro-Key: ' . $keyId, 'X-AbcPro-Timestamp: ' . $ts, 'X-AbcPro-Signature: ' . $sig, ], ]); curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $code >= 200 && $code < 300; } /** Append a failed payload to the local retry queue (JSON lines). */ function abcpro_queue(array $sale, string $reason): void { $file = abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl'); $line = json_encode(['at' => date('c'), 'reason' => $reason, 'sale' => $sale], JSON_UNESCAPED_SLASHES); @file_put_contents((string)$file, $line . "\n", FILE_APPEND | LOCK_EX); } /** * Drain the local retry queue (call from the product's cron). Re-sends each * queued payload; keeps the ones that still fail. Safe because the hub is * idempotent on (project, external_id). */ function abcpro_flush_queue(): array { $file = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl'); if (!is_readable($file)) return ['sent' => 0, 'kept' => 0]; $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; @unlink($file); $sent = 0; $kept = 0; foreach ($lines as $line) { $row = json_decode($line, true); if (!is_array($row) || empty($row['sale'])) continue; if (abcpro_report_sale($row['sale'])) $sent++; else $kept++; // failure re-queues } return ['sent' => $sent, 'kept' => $kept]; }