AbcPro
Compensation PlanJoin freeSign in

← Partner integration overview

Drop-in connector — abcpro_connector.php

View raw / copy

Copy this file into your project and call Abcpro::reportSale(). It handles the HMAC signing, retries, and refunds. · Read the full contract →

<?php
/**
 * ════════════════════════════════════════════════════════════════════════════
 * AbcPro Connector — drop this ONE file into any project (GigPeach, GuestBookit,
 * BluSpots, future). It is the entire integration surface.
 * ════════════════════════════════════════════════════════════════════════════
 *
 * It does two things:
 *   1. abcpro_capture_ref()  — call on every page: reads ?ref=CODE, cookies it.
 *   2. abcpro_report_sale()  — call on checkout success: signs + POSTs the sale
 *      to the hub. Best-effort with a local retry queue, so the hub being down
 *      NEVER breaks the product's checkout.
 *
 * Configure via the product's environment (or define before requiring this):
 *   ABCPRO_URL      e.g. https://abcpro.com
 *   ABCPRO_PROJECT_KEY  e.g. gigpeach
 *   ABCPRO_KEY_ID       the api key id issued by the hub
 *   ABCPRO_SECRET       the shared HMAC secret
 *   ABCPRO_QUEUE_FILE   (optional) path for the local retry queue (JSON lines)
 *
 * No database and no framework required in the product — just this file + curl.
 */

declare(strict_types=1);

if (!function_exists('abcpro_cfg')) {
    function abcpro_cfg(string $key, ?string $default = null): ?string {
        if (defined($key)) return (string)constant($key);
        $v = getenv($key);
        return ($v === false || $v === '') ? $default : $v;
    }
}

/** The ?ref cookie name shared across a product's pages. */
if (!defined('ABCPRO_REF_COOKIE')) define('ABCPRO_REF_COOKIE', 'abcpro_ref');

/**
 * Capture a referral code from the URL (?ref=CODE) into a long-lived cookie so
 * it survives the journey to checkout. Returns the active ref code (or '').
 *
 * The cookie is set effectively permanent (10 years). Browsers cap cookies at
 * ~400 days, but that cap doesn't limit the RELATIONSHIP: the moment the visitor
 * buys or signs up, AbcPro binds them to this GC permanently (first-sponsor-wins,
 * never expires). The cookie is only the bridge before that first action.
 */
function abcpro_capture_ref(): string {
    $ref = '';
    if (!empty($_GET['ref'])) {
        $ref = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_GET['ref']));
        if ($ref !== '' && !headers_sent()) {
            setcookie(ABCPRO_REF_COOKIE, $ref, [
                'expires'  => 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];
}