Skip to content

NetSuite Integration

Syncs Volāre business records (clients, suppliers, bookings, payments) to NetSuite ERP via the REST API, using queue-based jobs with automatic retry and a polymorphic sync tracking system.

  • Sync client, supplier, booking, and payment data to NetSuite for accounting
  • Monitor sync status and retry failures from the Filament admin panel
  • Reconcile local records against NetSuite to detect drift
  • Create service items in NetSuite for booking upsell line items
Model (with HasNetsuiteSync trait)
-> Sync Job dispatched to `netsuite-sync` queue
-> AbstractNetsuiteSyncJob.handle()
-> NetsuiteClient (REST requests with retry + 401 re-auth)
-> NetsuiteAuthenticationService (OAuth 2.0 JWT assertion, cached token)
-> NetsuiteSync record updated (Pending -> Syncing -> Synced/Failed)

Key components:

  • NetsuiteAuthenticationService – OAuth 2.0 client_credentials flow with EC256 JWT assertion. Tokens are cached with a 5-minute buffer before expiry.
  • NetsuiteClient – REST client with configurable timeout, retry (with backoff), automatic 401 re-authentication, typed error handling (429 rate limit, 400/422 validation, 5xx server errors), and structured logging with credential sanitization.
  • NetsuiteSyncRegistry – Maps model types to their sync job class and NetSuite REST endpoint. Used by Artisan commands to dispatch jobs generically.
  • NetsuiteSync model – Polymorphic sync tracking. Stores netsuite_id, external_id, status, payload, attempts, and error.
  • HasNetsuiteSync trait – Applied to Booking, Client, Payment, Supplier. Provides netsuiteSync() relation, getOrCreateNetsuiteSync(), and needsNetsuiteSync().

Source: backend/app/Services/Netsuite/

Variable Description
NETSUITE_ENVIRONMENT sandbox or production (default: sandbox)
NETSUITE_ACCOUNT_ID NetSuite account ID (underscores replaced with hyphens for URLs)
NETSUITE_CLIENT_ID OAuth 2.0 client ID from NetSuite Integration record
NETSUITE_CLIENT_SECRET OAuth 2.0 client secret
NETSUITE_CERTIFICATE_ID Certificate ID for JWT signing
NETSUITE_PRIVATE_KEY_PATH Path to EC256 private key (default: storage/netsuite/private.pem)
Variable Default Description
NETSUITE_REST_URL Auto-derived from account ID Override REST API base URL
NETSUITE_TOKEN_URL Auto-derived from account ID Override token endpoint
NETSUITE_SANDBOX_TIMEOUT 30 Request timeout (seconds)
NETSUITE_SANDBOX_RETRY_ATTEMPTS 3 Max retry attempts
NETSUITE_SANDBOX_RETRY_DELAY 500 Retry delay (ms)
NETSUITE_LOGGING_ENABLED true Enable API request logging
NETSUITE_LOG_CHANNEL single Log channel
NETSUITE_QUEUE netsuite-sync Queue name for sync jobs
NETSUITE_SUBSIDIARY_ID 1 NetSuite subsidiary internal ID
NETSUITE_ITEM_FLIGHT – Service item ID for flight line items
NETSUITE_ITEM_HOTEL – Service item ID for hotel line items
NETSUITE_ITEM_ACTIVITY – Service item ID for activity line items
NETSUITE_ITEM_TRANSFER – Service item ID for transfer line items
NETSUITE_ITEM_INSURANCE – Service item ID for insurance line items
NETSUITE_ITEM_FLIGHT_UPGRADE – Service item ID for flight upgrade line items

Config file: backend/config/netsuite.php

Volāre Model NetSuite Record REST Endpoint Sync Job
Client Customer /record/v1/customer SyncClientToNetsuiteJob
Supplier Vendor /record/v1/vendor SyncSupplierToNetsuiteJob
Booking Invoice /record/v1/invoice SyncBookingToNetsuiteJob
Payment CustomerPayment /record/v1/customerPayment SyncPaymentToNetsuiteJob
Booking upsells VendorBill /record/v1/vendorBill SyncVendorBillToNetsuiteJob

The registry (NetsuiteSyncRegistry) covers the first four. SyncVendorBillToNetsuiteJob is standalone – it groups a booking’s upsells by supplier and creates one Vendor Bill per supplier, using the agreed contract cost when available.

Source: backend/app/Services/Netsuite/NetsuiteSyncRegistry.php

Tracked by NetsuiteSyncStatus enum:

  • Pending – Awaiting sync (initial state, or reset for retry)
  • Syncing – Job is currently executing
  • Synced – Successfully synced, netsuite_id is populated
  • Failed – Sync failed, error field has details
  • Skipped – Intentionally excluded from sync

Source: backend/app/Enums/NetsuiteSyncStatus.php

  • Dependency ordering: Client must be synced before its Booking or Payment. Supplier must be synced before its VendorBill. Jobs throw RuntimeException if the dependency is missing.
  • Idempotency: Each model gets a deterministic externalId (volare_{table}_{id}). POST creates new records, PUT updates existing ones (when netsuite_id is already set).
  • Retry: Jobs retry up to 3 times with backoff (30s, 60s, 180s). After all retries, the sync record is marked Failed. Non-retryable errors (e.g., validation) fail immediately without retrying.
  • Rate limiting: 429 responses release the job back to the queue with the Retry-After delay (default 60s). The sync record stays Pending during the wait.
  • Currency mapping: ISO codes mapped to NetSuite internal IDs in config/netsuite.php (currencies key). Currently: EUR, USD, CAD, GBP.
  • Invoice line items: Built from BookingUpsell records, mapped to NetSuite service items by upsell type. Falls back to a single “Flight” line if no upsells exist.
  • Vendor Bill cost: Finds the SupplierContractService line on the supplier’s active contract valid at the booking date. The contract lookup is contractsOnly() – addendums are supplier_contracts rows too, and an addendum is read through its parent, never as the contract itself. The amount billed then comes from SupplierContractRateResolver::agreedPrice(), which resolves the rate across the contract and its addendums (newest signed document wins), so a renegotiated rate is billed at the price in force instead of the superseded one printed on the contract line; the line’s own price stays the fallback when no signed document prices that rate. Withdrawals carry no price and are skipped by the resolver, so a service the supplier already delivered before it was withdrawn is still billed at its agreed rate. See which document governs a rate.

Dispatch sync jobs for all unsynced records.

Terminal window
vendor/bin/sail artisan netsuite:sync-all
vendor/bin/sail artisan netsuite:sync-all --model=client --limit=100
vendor/bin/sail artisan netsuite:sync-all --force # re-sync already synced records

Re-dispatch jobs for failed or pending sync records.

Terminal window
vendor/bin/sail artisan netsuite:retry-failed
vendor/bin/sail artisan netsuite:retry-failed --status=pending --model=booking --limit=50
vendor/bin/sail artisan netsuite:retry-failed --status=all

Compare local sync records against NetSuite. Reports verified, missing, and errored records.

Terminal window
vendor/bin/sail artisan netsuite:reconcile
vendor/bin/sail artisan netsuite:reconcile --fix # reset stuck/failed records

The --fix flag resets “syncing” records older than 1 hour to Failed, then resets all Failed records to Pending.

Create service items in NetSuite for each upsell type (flight, hotel, activity, transfer, insurance, flight_upgrade). Outputs the NETSUITE_ITEM_* env vars to add to .env.

Terminal window
vendor/bin/sail artisan netsuite:sync-service-items

Source: backend/app/Console/Commands/

Admin-only page under System > NetSuite Sync. Shows a table of all NetsuiteSync records with:

  • Filters by status and model type
  • Retry action on failed records (resets to Pending)
  • Auto-polls every 30 seconds

Stats overview (total, synced, failed, pending) displayed as header widget on the monitor page.

Source: backend/app/Filament/Pages/NetsuiteSyncMonitor.php, backend/app/Filament/Widgets/NetsuiteSyncStatsWidget.php

  1. Add the trait to your model: use HasNetsuiteSync;
  2. Create a sync job extending AbstractNetsuiteSyncJob. Implement buildPayload(), getEndpoint(), and getRecordType().
  3. Register in NetsuiteSyncRegistry::MODELS with the model class, job class, and NetSuite endpoint.
  4. Run netsuite:sync-all --model=yourmodel to dispatch.

Source: backend/app/Jobs/Netsuite/AbstractNetsuiteSyncJob.php for the base job contract.

All exceptions extend NetsuiteException and implement isRetryable():

Exception HTTP Status Retryable Description
NetsuiteException any No Base exception for all NetSuite errors
NetsuiteAuthenticationException 401 Yes OAuth token expired or credentials invalid
NetsuiteTimeoutException – Yes Connection timeout
NetsuiteRateLimitException 429 Yes Rate limit exceeded, carries retryAfter delay
NetsuiteValidationException 400/422 No Payload validation failed, carries validationErrors

Source: backend/app/Exceptions/Services/Netsuite/

Symptom Cause Fix
InvalidArgumentException: NetSuite account ID is not configured Missing env vars Set NETSUITE_ACCOUNT_ID, NETSUITE_CLIENT_ID, NETSUITE_CERTIFICATE_ID
NetsuiteAuthenticationException: Private key file not found Key not deployed Place EC256 private key at NETSUITE_PRIVATE_KEY_PATH (default: storage/netsuite/private.pem)
NetsuiteValidationException on sync Invalid payload data Check the error field on the sync record for NetSuite validation details
NetsuiteRateLimitException Too many API calls Job auto-retries after delay; reduce batch size in netsuite:sync-all --limit
RuntimeException: Client must be synced before booking Dependency not synced Sync clients first: netsuite:sync-all --model=client, then bookings
Sync stuck in “Syncing” state Job crashed mid-execution Run netsuite:reconcile --fix to reset stuck records
401 after token refresh Certificate/credentials rotated in NetSuite Update NETSUITE_CLIENT_ID, NETSUITE_CLIENT_SECRET, NETSUITE_CERTIFICATE_ID in .env
  • Config: backend/config/netsuite.php
  • Services: backend/app/Services/Netsuite/
  • Jobs: backend/app/Jobs/Netsuite/
  • Model: backend/app/Models/NetsuiteSync.php
  • Trait: backend/app/Traits/HasNetsuiteSync.php
  • Enum: backend/app/Enums/NetsuiteSyncStatus.php
  • Commands: backend/app/Console/Commands/Netsuite*.php
  • Exceptions: backend/app/Exceptions/Services/Netsuite/