soap-client.tsโข11 kB
/**
* RS.ge SOAP API Client
*
* Handles all communication with RS.ge Waybill SOAP service
*
* ๐ TEACHING: SOAP Client Architecture
* =====================================
* This client:
* 1. Builds SOAP XML requests
* 2. Sends HTTP POST requests
* 3. Parses SOAP XML responses
* 4. Handles errors and retries
* 5. Validates responses
*
* Key Features:
* - โ
Proper XML parsing (no phantom elements!)
* - โ
Automatic retries with exponential backoff
* - โ
Comprehensive logging
* - โ
Type-safe responses
* - โ
Error handling
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import { buildSoapRequest, buildWaybillXml, parseSoapResponse } from './xml-parser.js';
import { getLogger } from '../utils/logger.js';
import { SoapApiError, AuthenticationError, retryWithBackoff } from '../utils/error-handler.js';
import { DateRange } from '../utils/date-range.js';
import type { ApiConfig } from '../config/config.schema.js';
import type {
Waybill,
GetWaybillsResponse,
GetWaybillResponse,
SaveWaybillResponse,
SendWaybillResponse,
CloseWaybillResponse,
ConfirmWaybillResponse,
RejectWaybillResponse,
GetErrorCodesResponse,
GetAkcizCodesResponse,
GetWaybillTypesResponse,
GetNameFromTinResponse,
GetServiceUsersResponse,
} from '../types/waybill.types.js';
import { normalizeToArray, normalizeWaybill } from '../types/waybill.types.js';
/**
* RS.ge SOAP Client
*/
export class RsWaybillSoapClient {
private client: AxiosInstance;
private config: ApiConfig;
private credentials: { user: string; password: string };
private logger = getLogger();
constructor(
config: ApiConfig,
credentials: { user: string; password: string }
) {
this.config = config;
this.credentials = credentials;
this.client = axios.create({
baseURL: config.baseUrl,
timeout: config.timeout,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
},
});
// Log configuration
this.logger.info('SOAP client initialized', {
baseUrl: config.baseUrl,
timeout: config.timeout,
retries: config.retries,
});
}
/**
* Call SOAP method with automatic retries
*/
private async callSoap<T>(
method: string,
params: Record<string, any>,
namespace?: string
): Promise<T> {
return retryWithBackoff(
async () => {
const soapRequest = buildSoapRequest(method, params, namespace);
this.logger.debug(`SOAP request: ${method}`, { params });
try {
const response = await this.client.post('', soapRequest, {
headers: {
SOAPAction: `http://tempuri.org/${method}`,
},
});
this.logger.debug(`SOAP response: ${method}`, {
status: response.status,
dataLength: response.data?.length,
});
const parsed = parseSoapResponse<T>(response.data);
return parsed;
} catch (error) {
if (axios.isAxiosError(error)) {
throw this.handleAxiosError(error, method);
}
throw error;
}
},
{
maxRetries: this.config.retries,
initialDelay: this.config.retryDelay,
shouldRetry: (error) => {
// Retry on network errors and 5xx server errors
if (axios.isAxiosError(error)) {
return !error.response || error.response.status >= 500;
}
return false;
},
}
);
}
/**
* Handle Axios errors
*/
private handleAxiosError(error: AxiosError, method: string): Error {
if (error.response) {
const status = error.response.status;
const data = error.response.data;
this.logger.error(`SOAP error: ${method}`, {
status,
data: typeof data === 'string' ? data.substring(0, 500) : data,
});
if (status === 401 || status === 403) {
return new AuthenticationError(
'Invalid RS.ge credentials. Please check your service user and password.'
);
}
if (status >= 500) {
return new SoapApiError(
`RS.ge API server error (${status}). The service may be temporarily unavailable.`
);
}
return new SoapApiError(
`RS.ge API error (${status}): ${error.message}`
);
}
if (error.request) {
this.logger.error(`Network error: ${method}`, {
message: error.message,
code: error.code,
});
return new SoapApiError(
`Cannot connect to RS.ge API at ${this.config.baseUrl}. ` +
`Please check your internet connection and try again.`
);
}
return new SoapApiError(`Request failed: ${error.message}`);
}
/**
* Get waybills with date filtering
*
* โ ๏ธ CRITICAL REQUIREMENTS:
* 1. Uses get_waybills operation (NOT get_waybills_v1)
* 2. Uses create_date_s/e parameters in ISO datetime format (YYYY-MM-DDTHH:MM:SS)
* 3. End date is automatically set to next day at 00:00:00 to include the entire day
* 4. Includes seller_un_id extracted from credentials
*
* @param startDate Start date (YYYY-MM-DD) - REQUIRED
* @param endDate End date (YYYY-MM-DD) - REQUIRED
* @param buyerTin Optional buyer TIN filter
*/
async getWaybillsV1(
startDate?: string,
endDate?: string,
buyerTin?: string
): Promise<Waybill[]> {
// Validate date range
if (!startDate || !endDate) {
throw new SoapApiError(
'Start date and end date are required. ' +
'RS.ge API requires date ranges to be specified.'
);
}
// Create and validate date range
const dateRange = DateRange.create(startDate, endDate);
const apiDates = dateRange.toApiFormat();
// Extract seller_un_id from credentials (e.g., "4053098841:405309884" -> "405309884")
const sellerUnId = this.credentials.user.includes(':')
? this.credentials.user.split(':')[1]
: '';
this.logger.info(`Querying waybills: ${dateRange.toString()}`);
const response = await this.callSoap<GetWaybillsResponse>('get_waybills', {
su: this.credentials.user,
sp: this.credentials.password,
seller_un_id: sellerUnId,
create_date_s: apiDates.start,
create_date_e: apiDates.end,
buyer_tin: buyerTin || '',
});
const waybills = normalizeToArray(response.WAYBILL_LIST?.WAYBILL).map(normalizeWaybill);
this.logger.info(`Received ${waybills.length} waybills from API`);
return waybills;
}
/**
* Get single waybill by ID
*/
async getWaybill(waybillId: string): Promise<Waybill | null> {
const response = await this.callSoap<GetWaybillResponse>('get_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill_id: waybillId,
});
return response.WAYBILL ? normalizeWaybill(response.WAYBILL) : null;
}
/**
* Save (create or update) waybill
*
* @param waybill Waybill data
* @returns Created/updated waybill ID
*/
async saveWaybill(waybill: Waybill): Promise<string> {
const waybillXml = buildWaybillXml(waybill);
const response = await this.callSoap<SaveWaybillResponse>('save_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill: waybillXml,
});
if (!response.SAVE_WAYBILL_RESULT) {
throw new SoapApiError('save_waybill did not return a result');
}
return String(response.SAVE_WAYBILL_RESULT);
}
/**
* Send waybill (submit to RS.ge)
*/
async sendWaybill(waybillId: string): Promise<string> {
const response = await this.callSoap<SendWaybillResponse>('send_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill_id: waybillId,
});
return response.SEND_WAYBILL_RESULT || 'Success';
}
/**
* Close waybill
*/
async closeWaybill(waybillId: string): Promise<string> {
const response = await this.callSoap<CloseWaybillResponse>('close_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill_id: waybillId,
});
return response.CLOSE_WAYBILL_RESULT || 'Success';
}
/**
* Confirm waybill (buyer confirms receipt)
*/
async confirmWaybill(waybillId: string): Promise<string> {
const response = await this.callSoap<ConfirmWaybillResponse>('confirm_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill_id: waybillId,
});
return response.CONFIRM_WAYBILL_RESULT || 'Success';
}
/**
* Reject waybill (buyer rejects)
*/
async rejectWaybill(waybillId: string): Promise<string> {
const response = await this.callSoap<RejectWaybillResponse>('reject_waybill', {
su: this.credentials.user,
sp: this.credentials.password,
waybill_id: waybillId,
});
return response.REJECT_WAYBILL_RESULT || 'Success';
}
/**
* Get error codes dictionary
*/
async getErrorCodes(): Promise<any[]> {
const response = await this.callSoap<GetErrorCodesResponse>('get_error_codes', {
su: this.credentials.user,
sp: this.credentials.password,
});
return normalizeToArray(response.ERROR_CODES?.ERROR_CODE);
}
/**
* Get akciz codes (excise codes)
*
* @param searchText Optional search text filter
*/
async getAkcizCodes(searchText?: string): Promise<any[]> {
const response = await this.callSoap<GetAkcizCodesResponse>('get_akciz_codes', {
su: this.credentials.user,
sp: this.credentials.password,
s_text: searchText || '',
});
return normalizeToArray(response.AKCIZ_CODES?.AKCIZ);
}
/**
* Get waybill types
*/
async getWaybillTypes(): Promise<any[]> {
const response = await this.callSoap<GetWaybillTypesResponse>('get_waybill_types', {
su: this.credentials.user,
sp: this.credentials.password,
});
return normalizeToArray(response.WAYBILL_TYPES?.TYPE);
}
/**
* Get company name from TIN
*/
async getNameFromTin(tin: string): Promise<string> {
const response = await this.callSoap<GetNameFromTinResponse>('get_name_from_tin', {
su: this.credentials.user,
sp: this.credentials.password,
tin: tin,
});
return response.NAME || '';
}
/**
* Get service users
*/
async getServiceUsers(): Promise<any[]> {
const response = await this.callSoap<GetServiceUsersResponse>('get_service_users', {
user_name: this.credentials.user.split(':')[0], // Extract username part
user_password: this.credentials.password,
});
return normalizeToArray(response.USERS?.USER);
}
/**
* Check service user credentials
*/
async checkServiceUser(): Promise<boolean> {
try {
await this.callSoap('chek_service_user', {
su: this.credentials.user,
sp: this.credentials.password,
});
return true;
} catch (error) {
if (error instanceof AuthenticationError) {
return false;
}
throw error;
}
}
}