Adding Fund, Campaign or Appeal to gift/constituent records

I am currently building an API from NXT to our web portal for mission trips. I have been able to get information for both gifts and constituents to the web portal. But when I filter for funds or campaigns or appeals I see the gifts and constituents still but I am not able to match any of them with a fund, appeal or campaign. I tried all three an nothing popped.

Comments

  • Alex Wong
    Alex Wong Community All-Star
    Ninth Anniversary Kudos 5 Facilitator 3 Raiser's Edge NXT Fall 2025 Product Update Briefing Badge

    @Stew Sheckler
    i can't understand what is your question, a little more info and clarity will help me understand to help you

  • @Alex Wong
    I'll give you my current code and see if you can tell me why I am getting a 200 OK but I am not pulling any gift/constituent data only getting my Campaign and Fund Data.

    What I am trying to do is get all the information from NXT about our Mission Trips for funding and donations and push it to a webportal we are using called ServiceReef. They have specific categories that I am struggling to pull from NXT in an orderly fashion so they will sync.

    I hope that makes sense.

    <?php

    namespace App\\Services;

    use GuzzleHttp\\Client;

    use App\\Services\\LogHelper;

    use App\\Services\\NXTTokenService;

    class NXTService

    {

    private $constituentCache = [];

    private $lastRequestTime = 0;

    private $requestsInLastSecond = 0;

    private $dailyRequestCount = 0;

    private $dailyQuotaReset = 0;

    private $maxRequestsPerSecond = 10;

    private $maxDailyRequests = 25000; // Standard tier limit

    private function handleRateLimiting(\\Exception $e)

    {

    if ($e->getCode() === 429) { // Rate limit exceeded

    $retryAfter = $e->getResponse()->getHeader('Retry-After')[0] ?? 1;

    LogHelper::log('INFO', '[NXTService] Rate limit exceeded, waiting...', ['retry_after' => $retryAfter]);

    sleep((int)$retryAfter);

    return true;

    } elseif ($e->getCode() === 403) { // Daily quota exceeded

    $retryAfter = $e->getResponse()->getHeader('Retry-After')[0] ?? 3600;

    LogHelper::log('ERROR', '[NXTService] Daily quota exceeded', ['retry_after' => $retryAfter]);

    throw new \\Exception('Daily API quota exceeded. Please try again later.');

    }

    return false;

    }

    private function checkRateLimits()

    {

    $now = time();

    // Reset daily counter if needed (24 hour period)

    if ($now - $this->dailyQuotaReset >= 86400) {

    $this->dailyRequestCount = 0;

    $this->dailyQuotaReset = $now;

    }

    // Check if we've exceeded daily quota

    if ($this->dailyRequestCount >= $this->maxDailyRequests) {

    throw new \\Exception('Daily API quota exceeded. Please try again later.');

    }

    // Handle per-second rate limiting

    if ($now === $this->lastRequestTime) {

    $this->requestsInLastSecond++;

    if ($this->requestsInLastSecond >= $this->maxRequestsPerSecond) {

    usleep(100000); // Sleep 100ms if we're at the rate limit

    $this->requestsInLastSecond = 0;

    $this->lastRequestTime = time();

    }

    } else {

    $this->requestsInLastSecond = 1;

    $this->lastRequestTime = $now;

    }

    $this->dailyRequestCount++;

    }

    /**

    * Cached array of Mission Trip fund IDs

    * @var array|null

    */

    private $missionTripFundIds = null;

    private $client;

    private $baseUrl;

    private $accessToken;

    private $tokenFetchedAt;

    private $tokenExpiresIn;

    public function __construct()

    {

    $this->baseUrl = $_ENV['NXT_BASE_URL'] ?? '';

    if (empty($this->baseUrl)) {

    LogHelper::log('ERROR', '[NXTService] NXT_BASE_URL is missing or empty!', []);

    echo "[ERROR] Required environment variable NXT_BASE_URL is missing or empty!\\n";

    var_dump($_ENV['NXT_BASE_URL']);

    exit(1);

    }

    LogHelper::log('INFO', 'NXTService NXT_BASE_URL', ['NXT_BASE_URL' => $this->baseUrl]);

    $this->loadAccessToken();

    $this->initClient();

    }

    private function loadAccessToken()

    {

    $tokenService = new NXTTokenService();

    LogHelper::log('INFO', '[NXTService] Loading access token from NXTTokenService', []);

    $accessToken = $tokenService->getValidAccessToken();

    if (!$accessToken) {

    LogHelper::log('ERROR', 'NXTService: Failed to load access token from NXTTokenService.', []);

    } else {

    LogHelper::log('INFO', '[NXTService] Access token loaded successfully', []);

    }

    $this->accessToken = $accessToken;

    $this->tokenFetchedAt = time();

    $this->tokenExpiresIn = 3600;

    }

    private function saveAccessToken($token, $expiresIn)

    {

    LogHelper::log('INFO', '[NXTService] Saving new access token', ['expires_in' => $expiresIn]);

    $this->accessToken = $token;

    $this->tokenFetchedAt = time();

    $this->tokenExpiresIn = $expiresIn;

    // Optionally persist to file or cache if needed

    // For now, just update the instance

    }

    private function initClient()

    {

    $this->client = new Client([

    'base_uri' => $this->baseUrl,

    'headers' => [

    'Bb-Api-Subscription-Key' => $_ENV['NXT_SUBSCRIPTION_KEY'],

    'Authorization' => 'Bearer ' . $this->accessToken,

    'Content-Type' => 'application/json',

    ],

    'timeout' => 20, // Increased to 20 seconds to handle larger batches of data

    'connect_timeout' => 5 // Keep at 5 seconds as connection issues manifest quickly

    ]);

    LogHelper::log('INFO', '[NXTService] Guzzle Client initialized', [

    'base_uri' => $this->baseUrl,

    'headers' => [

    'Authorization' => 'Bearer ' . $this->accessToken,

    'Bb-Api-Subscription-Key' => $_ENV['NXT_SUBSCRIPTION_KEY'],

    'Content-Type' => 'application/json',

    ]

    ]);

    }

    private function isTokenExpired()

    {

    if (!$this->accessToken) return true;

    // Consider token expired if within 2 minutes of expiry

    return (time() - $this->tokenFetchedAt) > ($this->tokenExpiresIn - 120);

    }

    private function refreshAccessToken()

    {

    $tokenService = new NXTTokenService();

    LogHelper::log('INFO', '[NXTService] Attempting to refresh Blackbaud NXT access token', []);

    try {

    $result = $tokenService->refreshAccessToken();

    if ($result && isset($result['access_token'])) {

    LogHelper::log('INFO', '[NXTService] Access token refreshed successfully', []);

    $this->saveAccessToken($result['access_token'], $result['expires_in']);

    $this->initClient();

    return true;

    }

    LogHelper::log('ERROR', 'NXTService: Failed to refresh Blackbaud NXT access token - no access_token in response.', []);

    } catch (\\Exception $e) {

    LogHelper::log('ERROR', 'NXTService: Exception during refreshAccessToken', ['error' => $e->getMessage()]);

    return false;

    }

    }

    public function fetchMissionTripDonations()

    {

    LogHelper::log('INFO', '[NXTService] Starting fetchMissionTripDonations', []);

    if ($this->isTokenExpired()) {

    $this->refreshAccessToken();

    }

    try {

    $startDate = '2025-01-01T00:00:00-04:00';

    $endDate = '2025-12-31T23:59:59-04:00';

    // Step 1: Get all campaigns and filter for "Mission Trip"

    $campaignsResponse = $this->client->get('/fundraising/v1/campaigns');

    $campaigns = json_decode($campaignsResponse->getBody(), true)['value'] ?? [];

    // Debug: Log first campaign structure

    if (!empty($campaigns)) {

    LogHelper::log('DEBUG', '[NXTService] First campaign structure: ' . json_encode($campaigns[0], JSON_PRETTY_PRINT));

    }

    // Debug: Log all campaign descriptions

    $campaignDescs = array_map(function($campaign) {

    return $campaign['description'] ?? 'NO_DESCRIPTION';

    }, $campaigns);

    LogHelper::log('DEBUG', '[NXTService] All campaign descriptions: ' . json_encode($campaignDescs));

    $missionTripCampaigns = array_filter($campaigns, function ($campaign) {

    return ($campaign['description'] ?? '') === 'Mission Trips'||

    ($campaign['description'] ?? '') === '2025 Mission Trips';

    });

    $campaignIds = array_column($missionTripCampaigns, 'id');

    // Step 2: Get all funds and filter for "Mission Trip"

    $fundsResponse = $this->client->get('/fundraising/v1/funds');

    $funds = json_decode($fundsResponse->getBody(), true)['value'] ?? [];

    // Debug: Log first fund structure

    if (!empty($funds)) {

    LogHelper::log('DEBUG', '[NXTService] First fund structure: ' . json_encode($funds[0], JSON_PRETTY_PRINT));

    }

    // Debug: Log all fund descriptions

    $fundDescs = array_map(function($fund) {

    return $fund['description'] ?? 'NO_DESCRIPTION';

    }, $funds);

    LogHelper::log('DEBUG', '[NXTService] All fund descriptions: ' . json_encode($fundDescs));

    $missionTripFunds = array_filter($funds, function ($fund) {

    $desc = $fund['description'] ?? '';

    return strpos($desc, 'Mission Trip :') === 0;

    });

    $fundIds = array_column($missionTripFunds, 'id');

    LogHelper::log('INFO', 'Filtered campaigns: ' . json_encode($campaignIds));

    LogHelper::log('INFO', 'Filtered funds: ' . json_encode($fundIds));

    // Step 3: Fetch all gifts in 2025

    $offset = 0;

    $limit = 100;

    $gifts = [];

    do {

    // Build campaign filter

    $campaignFilter = count($campaignIds) > 0 ?

    'campaign_id in (' . implode(',', $campaignIds) . ')' : 'false';

    // Build fund filter

    $fundFilter = count($fundIds) > 0 ?

    'fund_id in (' . implode(',', $fundIds) . ')' : 'false';

    $response = $this->client->get('/gift/v1/gifts', [

    'query' => [

    'filter' => "(date ge '$startDate' and date le '$endDate') and (($campaignFilter) or ($fundFilter))",

    'skip' => $offset,

    'limit' => 100,

    'include' => 'constituent_id,amount,date,lookup_id,payment_method,payments,gift_splits,gift_status,is_anonymous,fund,campaign'

    ]

    ]);

    $batch = json_decode($response->getBody(), true)['value'] ?? [];

    foreach ($batch as $gift) {

    $gifts[] = $gift;

    }

    LogHelper::log('INFO', '[NXTService] Processed gifts page', [

    'page_size' => count($batch),

    'total_filtered_gifts' => count($gifts),

    'page' => ($offset / $limit) + 1

    ]);

    $offset += $limit;

    } while (count($batch) === $limit);

    LogHelper::log('INFO', 'Filtered mission trip gifts count: ' . count($gifts));

    return $gifts;

    } catch (\\Exception $e) {

    LogHelper::log('ERROR', '[NXTService] Error in fetchMissionTripDonations', [

    'error' => $e->getMessage()

    ]);

    throw $e;

    }

    }

    /**

    * Fetch and cache the fund IDs that are categorized as "Mission Trip".

    * Returns an array of fund IDs.

    */

    private function getMissionTripFundIds()

    {

    // Use cached list if available

    if ($this->missionTripFundIds !== null) {

    LogHelper::log('DEBUG', '[NXTService] Using cached mission trip fund IDs', [

    'count' => count($this->missionTripFundIds)

    ]);

    return $this->missionTripFundIds;

    }

    try {

    // Get all funds and filter for "Mission Trip"

    $fundsResponse = $this->client->get('/fundraising/v1/funds');

    $funds = json_decode($fundsResponse->getBody(), true)['value'] ?? [];

    $missionTripFunds = array_filter($funds, function ($fund) {

    return stripos($fund['description'] ?? '', 'mission trip') !== false;

    });

    $this->missionTripFundIds = array_column($missionTripFunds, <span style="

  • @Alex Wong
    I figured out my issues with the grabbing gifts, funds, and constituents. The issue was how the crazy categories identify themselves. The fund categories have completely different fund id's than the ones listed in both the fundraising -→ fund_id and the gift -→ split gift -→ fund_id. That was throwing me way off. Right now I am working on optimizing my script so I don't have to check so many gifts for the information and get a Gateway time out.