1// Server-side Fieldpine API helper
2// Handles all communication with Fieldpine APIs securely
4// Fieldpine provides multiple API interfaces:
6// 1. OpenAPI2 - RESTful JSON API for general retail operations
7// - URL: /OpenApi2/{Resource}
8// - Methods: GET, POST, PUT, DELETE
9// - Auth: X-Api-Key header or FieldpineApiKey cookie
11// 2. eLink API (BUCK/DATI) - Comprehensive retail data API
12// - BUCK (GET/Read): /GNAP/j/buck?3={endpoint}&9={filters}
13// - DATI (POST/Write): /GNAP/j/buck (POST DATI XML in body)
14// - Auth: FieldpineApiKey cookie
15// - See: /docs/elink-api-reference.md for complete endpoint list
17// 3. GNAP Protocol - Generic network application protocol
18// - Base: /GNAP/{method}/{endpoint}
19// - Format prefixes: j (JSON numeric), J (JSON named), M (metadata)
21// This class provides unified access to all Fieldpine APIs with:
22// - Authentication management (API key / cookie)
25// - Type-safe method wrappers
27// ========================================
28// Fieldpine Constants & Helpers
29// Extracted from Fieldpine's reference implementation
30// ========================================
32// Field Number Constants
33export const FIELDPINE_FIELDS = {
38 PRODUCT_BARCODE: 'f501',
42 CUSTOMER_NAME: 'f101',
43 CUSTOMER_ACCOUNT_LINK: 'f132',
48 ACCOUNT_CREDIT_LIMIT: 'f103',
49 ACCOUNT_FLOOR_LIMIT: 'f105',
50 ACCOUNT_BALANCE: 'f114',
51 ACCOUNT_RETIRED: 'f147',
52 ACCOUNT_EMAIL: 'f1100',
53 ACCOUNT_RECEIPT_FORMAT: 'f1105',
54 ACCOUNT_EXTERNAL_ID: 'f184',
57 SALE_DATETIME: 'f110',
58 SALE_STORE_ID: 'f120',
62 LOCATION_NAME: 'f101',
69// Field predicate operators
70export const FIELD_OPERATORS = {
74 GREATER_THAN_EQUAL: '5',
75 CONTAINS: '8', // Default for text search
81// Goods In History transaction types
82export const GIH_TRANSACTION_TYPES = {
85 102: 'Transfer Received',
86 200: 'Supplier Return',
90 204: 'Stocktake Adjustment',
95 * Escape XML content for safe inclusion in DATI packets
97export function escapeXml(value: string | number): string {
99 .replace(/&/g, '&')
100 .replace(/</g, '<')
101 .replace(/>/g, '>')
102 .replace(/"/g, '"')
103 .replace(/'/g, ''');
107 * Build a field predicate string for BUCK API queries
108 * @example buildFieldPredicate('f501', '0', '123456') // "f501,0,123456"
110export function buildFieldPredicate(fieldNum: string, operator: string, value: string | number): string {
111 return `${fieldNum},${operator},${value}`;
115 * Convert GIH transaction type code to readable string
117export function gihTypeToString(typeCode: number): string {
118 return GIH_TRANSACTION_TYPES[typeCode as keyof typeof GIH_TRANSACTION_TYPES] || `Type ${typeCode}`;
121interface RateLimitEntry {
126// Simple in-memory rate limiting (use Redis in production)
127const rateLimitMap = new Map<string, RateLimitEntry>();
129export class FieldpineServerApi {
130 private baseUrl: string;
131 private retailerId: string;
132 private apiKey: string;
134 constructor(baseUrl?: string, apiKey?: string) {
135 // Allow override via constructor (for session-based routing)
136 this.baseUrl = baseUrl || process.env.FIELDPINE_BASE_URL || "https://iig.cwanz.online";
137 this.retailerId = process.env.NEXT_PUBLIC_FIELDPINE_RETAILER_ID || "CWA";
138 this.apiKey = apiKey || process.env.FIELDPINE_API_KEY || process.env.FIELDPINE_AUTH_HEADER || "";
141 console.warn("FIELDPINE_API_KEY not set - using store-specific authentication");
146 * Get base URL (useful for debugging)
148 getBaseUrl(): string {
152 // Rate limiting check
153 private checkRateLimit(clientId: string, limit: number = 100, windowMs: number = 60000): boolean {
154 const now = Date.now();
155 const entry = rateLimitMap.get(clientId);
157 if (!entry || now > entry.resetTime) {
158 rateLimitMap.set(clientId, { count: 1, resetTime: now + windowMs });
162 if (entry.count >= limit) {
170 // Generic API call method with cookie support
171 public async apiCall(endpoint: string, options: {
174 params?: Record<string, any>;
175 useOpenApi?: boolean;
177 } = {}): Promise<any> {
178 const { method = "GET", body, params, useOpenApi = true, cookie } = options;
181 const apiBase = useOpenApi
182 ? `${this.baseUrl}/OpenApi2`
184 let url = `${apiBase}${endpoint}`;
187 const query = new URLSearchParams(params as Record<string, string>).toString();
188 url += (url.includes("?") ? "&" : "?") + query;
191 console.log('Making Fieldpine API call to:', url);
194 const headers: Record<string, string> = {
195 "Accept": "application/json",
196 "User-Agent": "EverydayPOS/1.0"
199 // Add authentication - prefer cookie over API key
201 headers["Cookie"] = `FieldpineApiKey=${cookie}`;
202 } else if (this.apiKey && useOpenApi) {
203 headers["X-Api-Key"] = this.apiKey;
207 headers["Content-Type"] = "application/json";
211 const response = await fetch(url, {
214 body: body ? JSON.stringify(body) : undefined,
218 throw new Error(`Fieldpine API error: ${response.status} ${response.statusText}`);
221 return await response.json();
223 console.error(`Fieldpine API call failed:`, { url, error });
229 * Call Fieldpine BUCK (eLink) API
231 * eLink is Fieldpine's comprehensive retail API for reading/writing data.
232 * BUCK endpoints are used for GET operations.
234 * @param paramsOrEndpoint - Either:
235 * - Object with query params: {"3": "retailmax.elink.products", "9": "f501,0,barcode"}
236 * - String endpoint: "/buck?3=retailmax.elink.products&9=f501,0,barcode"
237 * @param cookie - API key for authentication (passed as FieldpineApiKey cookie)
240 * - "3" or "8": eLink endpoint name (e.g., "retailmax.elink.products")
241 * - "8": Limit (max records)
242 * - "9": Field predicate/filter (format: "f{fieldnum},{operator},{value}")
243 * - "10": Field selection (which fields to return)
244 * - "13": Dimensions for grouping
245 * - "15": Format options
246 * - "16": Response detail level (4 = detailed)
251 * - 5: Greater than or equal
252 * - like: Pattern match
256 * // Get products by barcode
257 * buckApiCall({"3": "retailmax.elink.products", "10": "199", "9": "f501,0,123456"}, apiKey)
259 * // Get sales totals with string endpoint
260 * buckApiCall("/buck?3=retailmax.elink.sale.totals&9=f110,4,today", apiKey)
262 * @see /docs/elink-api-reference.md for full endpoint list
264 // BUCK API call method for alternative API access
266 paramsOrEndpoint: Record<string, string | string[]> | string,
268 baseUrlOverride?: string
270 const baseUrl = baseUrlOverride || this.baseUrl;
273 if (typeof paramsOrEndpoint === 'string') {
274 // If given a string starting with /, assume it's a relative path
275 if (paramsOrEndpoint.startsWith('/')) {
276 // Already has path, just prepend GNAP/j if not present
277 if (paramsOrEndpoint.includes('/GNAP/j/')) {
278 url = new URL(paramsOrEndpoint, baseUrl);
279 } else if (paramsOrEndpoint.startsWith('/buck')) {
280 url = new URL(`/GNAP/j${paramsOrEndpoint}`, baseUrl);
282 url = new URL(paramsOrEndpoint, baseUrl);
285 // Relative endpoint without /, add full path
286 url = new URL(`/GNAP/j/buck?${paramsOrEndpoint}`, baseUrl);
289 // Build URL from params Record
290 url = new URL('/GNAP/j/buck', baseUrl);
292 // Add all parameters to URL
293 Object.entries(paramsOrEndpoint).forEach(([key, value]) => {
294 if (Array.isArray(value)) {
295 value.forEach(v => url.searchParams.append(key, v));
297 url.searchParams.append(key, value);
302 const headers: Record<string, string> = {
303 'Accept': 'application/json',
307 headers['Cookie'] = `FieldpineApiKey=${cookie}`;
311 const response = await fetch(url.toString(), {
317 // On 403 Forbidden, the API key has likely expired
318 if (response.status === 403) {
319 throw new Error(`BUCK API error: 403 Forbidden - API key expired or invalid. Please refresh the page and log in again.`);
321 throw new Error(`BUCK API error: ${response.status} ${response.statusText}`);
324 return await response.json();
326 console.error(`BUCK API call failed:`, { url: url.toString(), error });
332 * Call Fieldpine DATI API
334 * DATI is used for write operations (create, update, delete).
335 * Sends XML data to modify records in Fieldpine.
337 * @param xml - XML payload (DATI format)
338 * @param cookie - API key for authentication
343 * <f8_s>retailmax.elink.customers.edit</f8_s>
345 * <f100_E>12345</f100_E>
346 * <f101_s>John Smith</f101_s>
350 async datiApiCall(xml: string, cookie?: string): Promise<{ success: boolean; data?: any; error?: string }> {
351 const url = new URL('/GNAP/j/buck', this.baseUrl);
353 const headers: Record<string, string> = {
354 'Content-Type': 'application/xml',
355 'Accept': 'application/json',
359 headers['Cookie'] = `FieldpineApiKey=${cookie}`;
363 const response = await fetch(url.toString(), {
370 const errorText = await response.text();
371 console.error(`DATI API error: ${response.status}`, errorText);
374 error: `DATI API error: ${response.status} ${response.statusText}`,
378 const data = await response.json();
380 // Check if the response indicates success
381 if (data && (data.error || data.Status === 'Error')) {
384 error: data.error || data.Message || 'DATI operation failed',
392 } catch (error: any) {
393 console.error(`DATI API call failed:`, error);
396 error: error.message || 'DATI API call failed',
401 // Authentication using correct CWA format
402 async authenticate(username: string, password: string, storeUrl?: string): Promise<{ success: boolean; data?: any; error?: string; cookie?: string }> {
404 // Use provided store URL or fall back to default baseUrl
405 const authUrl = storeUrl || this.baseUrl;
406 console.log(`Attempting CWA authentication for user: ${username} at ${authUrl}`);
408 // Demo credentials for testing
409 if ((username === 'demo' && password === 'demo') ||
410 (username === 'admin' && password === 'admin')) {
411 console.log('Demo credentials accepted');
419 HomePage: '/pages/home'
424 // Use correct CWA login endpoint format
425 const loginUrl = `${authUrl}/cwlogin`;
433 console.log(`Trying CWA auth at: ${loginUrl}`);
435 const response = await fetch(loginUrl, {
438 'Content-Type': 'application/json',
439 'Accept': 'application/json',
440 'User-Agent': 'EverydayPOS/1.0'
442 body: JSON.stringify(loginData)
446 console.log(`Auth response failed with status: ${response.status}`);
449 error: `Authentication failed: Server returned ${response.status}`
453 // Extract the FieldpineApiKey cookie from response headers
454 const setCookieHeader = response.headers.get('set-cookie');
455 let fieldpineApiKey = '';
457 if (setCookieHeader) {
458 const cookieMatch = setCookieHeader.match(/FieldpineApiKey=([^;]+)/);
460 fieldpineApiKey = cookieMatch[1];
461 console.log('Extracted FieldpineApiKey from cookie');
465 const result = await response.json();
466 console.log(`Auth response:`, result);
468 // Check for successful login using CWA format - also extract ApiKey from response
469 if (result.data && result.data.valid === 1) {
470 // Use ApiKey from response data if no cookie was found
471 if (!fieldpineApiKey && result.data.ApiKey) {
472 fieldpineApiKey = result.data.ApiKey;
473 console.log('Using ApiKey from response data');
482 HomePage: result.data.HomePage || '/pages/home',
483 ApiKey: result.data.ApiKey,
484 RmSystem: result.data.RmSystem,
487 cookie: fieldpineApiKey
493 error: `Invalid credentials`
497 console.error('CWA Authentication error:', error);
500 error: `Authentication service error: ${error instanceof Error ? error.message : 'Unknown error'}`
505 // Product operations with optional cookie authentication
506 async getProducts(params: { search?: string; plu?: string; barcode?: string; limit?: number } = {}, cookie?: string): Promise<any> {
508 // Map 'search' to 'EnglishQuery' for OpenAPI compatibility
509 const apiParams: Record<string, string> = {};
510 if (params.search) apiParams.EnglishQuery = params.search;
511 if (params.plu) apiParams.plu = params.plu;
512 if (params.barcode) apiParams.filter = `barcode=${params.barcode}`;
513 if (params.limit) apiParams.limit = params.limit.toString();
515 // Try main API first
516 return await this.apiCall("/Products", { params: apiParams, cookie });
518 console.log('Fieldpine API call failed:', { url: `${this.baseUrl}/OpenApi2/Products`, error });
522 // Try BUCK API fallback with consistent method
523 const searchTerm = params.search || params.plu || params.barcode || '';
524 let buckParams: Record<string, string> = {
525 "3": "retailmax.elink.products.list",
526 "8": (params.limit || 30).toString(),
527 "9": `f101,8,${encodeURIComponent(searchTerm)}` // f101 = Name/Description field
530 console.log('Trying BUCK API fallback:', `${this.baseUrl}/GNAP/j/buck?${Object.entries(buckParams).map(([k, v]) => `${k}=${v}`).join('&')}`);
532 const buckResponse = await this.buckApiCall(buckParams, cookie);
533 console.log('BUCK API Response:', JSON.stringify(buckResponse, null, 2));
535 // Handle BUCK response format
536 if (buckResponse && buckResponse.RootType === "ARAY") {
537 if (buckResponse.DATS && Array.isArray(buckResponse.DATS)) {
538 const products = buckResponse.DATS.map((item: any) => ({
540 name: item.f101 || item.f500,
542 sellprice: item.f103,
543 stocklevel: item.f106 || 0,
547 console.log('Products API Response:', { data: products });
548 console.log('Final product data:', JSON.stringify(products, null, 2));
549 return { data: products };
551 // Empty result from BUCK API
552 console.log('BUCK API returned empty result');
553 console.log('Products API Response:', { data: [] });
554 console.log('Final product data:', []);
559 console.log('BUCK response does not match expected format, returning raw result');
560 console.log('Products API Response:', buckResponse);
561 console.log('Final product data:', []);
563 } catch (buckError) {
564 console.log('BUCK API also failed, returning empty result');
565 console.log('Final product data:', []);
570 async getProductById(id: string, cookie?: string): Promise<any> {
571 return this.apiCall(`/Product/${id}`, { cookie });
574 // Sales operations with optional cookie authentication
575 async createSale(saleData: any, cookie?: string, storeUrl?: string): Promise<any> {
576 const baseUrl = storeUrl || this.baseUrl;
577 const url = new URL('/OpenApi2/Sales', baseUrl);
579 const headers: Record<string, string> = {
580 'Content-Type': 'application/json',
581 'Accept': 'application/json',
582 'X-Fieldpine-CallerHandling': 'audit'
586 headers['Cookie'] = `FieldpineApiKey=${cookie}`;
589 console.log('Making Fieldpine Sale POST to:', url.toString());
590 console.log('Sale payload:', JSON.stringify(saleData, null, 2));
592 const response = await fetch(url.toString(), {
595 body: JSON.stringify(saleData)
599 const errorText = await response.text();
600 console.error('Sale creation failed:', response.status, errorText);
601 throw new Error(`Fieldpine API error: ${response.status} ${response.statusText}`);
604 return await response.json();
607 // Location operations with optional cookie authentication
608 async getLocations(params: { limit?: number } = {}, cookie?: string): Promise<any> {
610 return await this.apiCall("/Locations", { params, cookie });
612 console.warn('Location API not available, returning empty result');
617 // Supplier operations with optional cookie authentication
618 async getSuppliers(params: { search?: string; limit?: number } = {}, cookie?: string): Promise<any> {
620 // Try BUCK API first with format that works
621 let buckParams: Record<string, string> = {
622 "3": "retailmax.elink.suppliers",
624 "20": (params.limit || 100).toString(),
625 "99": Math.random().toString()
628 const buckResponse = await this.buckApiCall(buckParams, cookie);
629 console.log('BUCK Suppliers API Response:', buckResponse);
631 // Handle BUCK response format
632 if (buckResponse && buckResponse.RootType === "ARAY" && buckResponse.DATS && Array.isArray(buckResponse.DATS)) {
634 data: buckResponse.DATS
637 console.log('BUCK suppliers response contains no data or wrong format, trying main API');
639 } catch (buckError) {
640 console.warn('BUCK suppliers API failed, trying main API:', buckError);
644 // Fallback to main API - but clean search parameter first
645 const cleanParams = { ...params };
646 if (cleanParams.search === 'undefined' || !cleanParams.search?.trim()) {
647 delete cleanParams.search;
649 return await this.apiCall("/Supplier", { params: cleanParams, cookie });
651 console.warn('Supplier API not available, returning empty result');
656 // Customer operations with optional cookie authentication
657 async getCustomers(params: { search?: string; limit?: number } = {}, cookie?: string): Promise<any> {
659 // Try BUCK API first with format that works
660 let buckParams: Record<string, string> = {
661 "3": "retailmax.elink.customers",
663 "20": (params.limit || 100).toString(),
664 "99": Math.random().toString()
667 const buckResponse = await this.buckApiCall(buckParams, cookie);
668 console.log('BUCK Customers API Response:', buckResponse);
670 // Handle BUCK response format
671 if (buckResponse && buckResponse.RootType === "ARAY" && buckResponse.DATS && Array.isArray(buckResponse.DATS)) {
673 data: buckResponse.DATS
676 console.log('BUCK customers response contains no data or wrong format, trying main API');
678 } catch (buckError) {
679 console.warn('BUCK customers API failed, trying main API:', buckError);
683 // Fallback to main API - but clean search parameter first
684 const cleanParams = { ...params };
685 if (cleanParams.search === 'undefined' || !cleanParams.search?.trim()) {
686 delete cleanParams.search;
688 return await this.apiCall("/Customer", { params: cleanParams, cookie });
690 console.warn('Customer API not available, returning empty result');
695 async getCustomerById(customerId: string | number, cookie?: string): Promise<any> {
697 // Use BUCK API to get a single customer with account details
698 let buckParams: Record<string, string> = {
699 "3": "retailmax.elink.customers",
700 "9": `f100,0,${customerId}`,
701 "10": "132,1130,153,154,1131,1132", // Additional fields including account name fields
702 "99": Math.random().toString()
705 const buckResponse = await this.buckApiCall(buckParams, cookie);
706 console.log('BUCK Customer By ID API Response:', buckResponse);
708 // Handle BUCK response format
709 if (buckResponse && buckResponse.DATS && Array.isArray(buckResponse.DATS)) {
710 const customerData = buckResponse.DATS[0];
712 // If customer has an account link (f132), fetch the account details
713 if (customerData && customerData.f132 && customerData.f132 > 0) {
715 const accountParams: Record<string, string> = {
716 "3": "retailmax.elink.accounts",
717 "9": `f100,0,${customerData.f132}`,
718 "99": Math.random().toString()
721 const accountResponse = await this.buckApiCall(accountParams, cookie);
722 console.log('Account details response:', accountResponse);
724 if (accountResponse && accountResponse.DATS && accountResponse.DATS[0]) {
725 // Add account name to customer data
726 customerData.accountName = accountResponse.DATS[0].f101;
727 customerData.accountBalance = accountResponse.DATS[0].f114 || 0;
730 console.error('Failed to fetch account details:', error);
734 return buckResponse.DATS;
739 console.error('Failed to fetch customer by ID:', error);
744 // Rate limit check for client requests
745 checkClientRateLimit(clientId: string): boolean {
746 return this.checkRateLimit(clientId, 100, 60000); // 100 requests per minute
749 // ========================================
750 // eLink API Helper Methods
751 // ========================================
752 // Based on official Fieldpine eLink API documentation
753 // See: /docs/elink-api-reference.md
756 * Get product stock levels
757 * eLink: retailmax.elink.product.stocklevels
759 async getProductStockLevels(productId: string, cookie?: string): Promise<any> {
760 return this.buckApiCall({
761 "3": "retailmax.elink.product.stocklevels",
762 "9": `f100,0,${productId}`
767 * Get all product stock levels (no product filter)
768 * eLink: retailmax.elink.product.stocklevels
769 * Returns DATS array with Pid, Description, Qty, Locid, LocationName, DispatchWait
771 async getAllProductStockLevels(cookie?: string): Promise<any> {
772 return this.buckApiCall({
773 "3": "retailmax.elink.product.stocklevels",
774 "100": "2" // SelectionRange: specific
779 * Get product media/images
780 * eLink: retailmax.elink.media.list
782 async getProductMedia(params: { productId?: string; limit?: number } = {}, cookie?: string): Promise<any> {
783 const buckParams: Record<string, string> = {
784 "3": "retailmax.elink.media.list"
787 if (params.productId) buckParams["9"] = `f100,0,${params.productId}`;
788 if (params.limit) buckParams["8"] = params.limit.toString();
790 return this.buckApiCall(buckParams, cookie);
794 * Get loyalty card details
795 * eLink: retailmax.elink.cardinquiry
797 async getCardInquiry(cardNumber: string, cookie?: string): Promise<any> {
798 return this.buckApiCall({
799 "3": "retailmax.elink.cardinquiry",
800 "9": `f100,0,${cardNumber}`
806 * eLink: retailmax.elink.staff.list
808 async getStaffList(params: { limit?: number; search?: string } = {}, cookie?: string): Promise<any> {
809 const buckParams: Record<string, string> = {
810 "3": "retailmax.elink.staff.list",
814 if (params.limit) buckParams["8"] = params.limit.toString();
815 if (params.search) buckParams["9"] = `f101,like,${params.search}`;
817 return this.buckApiCall(buckParams, cookie);
821 * Get staff usage/activity
822 * eLink: retailmax.elink.staff.used.list
824 async getStaffUsage(cookie?: string): Promise<any> {
825 return this.buckApiCall({
826 "3": "retailmax.elink.staff.used.list"
831 * Get department statistics for today
832 * eLink: retailmax.elink.stats.today.department
834 async getDepartmentStatsToday(cookie?: string): Promise<any> {
835 return this.buckApiCall({
836 "3": "retailmax.elink.stats.today.department"
841 * Get product Pareto analysis (top sellers)
842 * eLink: retailmax.elink.product.paretolist
844 async getProductPareto(params: { limit?: number; storeId?: string } = {}, cookie?: string): Promise<any> {
845 const buckParams: Record<string, string> = {
846 "3": "retailmax.elink.product.paretolist"
849 if (params.limit) buckParams["8"] = params.limit.toString();
850 if (params.storeId) buckParams["9"] = `f101,0,${params.storeId}`;
852 return this.buckApiCall(buckParams, cookie);
856 * Get flat sale list (denormalized sales with items and payments)
857 * eLink: retailmax.elink.saleflat.list
859 async getSaleFlatList(params: {
864 } = {}, cookie?: string): Promise<any> {
865 const buckParams: Record<string, string> = {
866 "3": "retailmax.elink.saleflat.list"
869 if (params.limit) buckParams["8"] = params.limit.toString();
870 if (params.startDate) buckParams["9"] = `f110,5,${params.startDate}`;
871 if (params.storeId) buckParams["9"] = `f120,0,${params.storeId}`;
873 return this.buckApiCall(buckParams, cookie);
877 * Get single sale details
878 * eLink: retailmax.elink.sale.fetch
880 async getSaleById(saleId: string, cookie?: string): Promise<any> {
881 return this.buckApiCall({
882 "3": "retailmax.elink.sale.fetch",
883 "9": `f100,0,${saleId}`
889 * eLink: retailmax.elink.pricemap.list
891 async getPriceMapList(params: { limit?: number } = {}, cookie?: string): Promise<any> {
892 const buckParams: Record<string, string> = {
893 "3": "retailmax.elink.pricemap.list"
896 if (params.limit) buckParams["8"] = params.limit.toString();
898 return this.buckApiCall(buckParams, cookie);
902 * Decode supplier pricebook
903 * eLink: retailmax.elink.supplier.pricebook.decode
905 async decodeSupplierPricebook(supplierId: string, cookie?: string): Promise<any> {
906 return this.buckApiCall({
907 "3": "retailmax.elink.supplier.pricebook.decode",
908 "9": `f100,0,${supplierId}`
913export const fieldpineServerApi = new FieldpineServerApi();