Files
binect-chrome/src/utils/binect-api.ts
tegwick 24daa4bf82 Improve consistency for server-discovered documents
- Separate "Has Errors" section for erroneous server documents (already
  uploaded but have validation errors)
- "Ready to Upload" section now only shows truly local documents
- Erroneous server docs show only "Delete from server" button (no retry)
- Improved metadata display: show document ID for server docs, hide
  unknown file size
- Clean up verbose debug logging

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 22:49:07 +01:00

601 lines
16 KiB
TypeScript

/**
* Binect API client
*
* This module wraps the @binect/js library to provide a simplified API
* for the Chrome extension. It delegates all API integration to the
* upstream library.
*/
import {
BinectClient,
BinectApiError,
BinectAuthError,
DocumentStatus,
isErroneous,
getErrors,
getStatusDescription,
type Document as BinectDocument,
type DocumentUploadOptions,
EnvelopeType,
FrankingType,
} from '@binect/js';
// Re-export types for backward compatibility
export interface Options {
simplex?: boolean; // if false, it's duplex
color?: boolean; // if false, it's black and white
envelope?: 'DINLANG' | 'C4';
franking?: 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING';
}
// Document type matching what popup.ts expects
export interface Document {
id: number;
filename: string;
numberOfPages?: number;
status: {
code: number;
text: string;
};
documentType: 'Letter' | 'SerialLetter';
letter?: {
letterType: 'LetterData' | 'Error';
letterData?: {
recipientAddress: string;
price: {
priceBeforeTax: number;
priceAfterTax: number;
unit: string;
taxInPercent: number;
};
international: boolean;
options: Options;
};
errors?: Array<{
code: number;
text: string;
blankText: string;
}>;
};
}
/**
* Custom error class for Binect API errors
* Wraps errors from @binect/js for backward compatibility
*/
export class BinectAPIError extends Error {
constructor(
message: string,
public statusCode?: number,
public response?: unknown
) {
super(message);
this.name = 'BinectAPIError';
}
/**
* Create from a @binect/js error
*/
static fromBinectError(error: BinectApiError | BinectAuthError): BinectAPIError {
if (error instanceof BinectAuthError) {
return new BinectAPIError('Invalid credentials', 401);
}
return new BinectAPIError(
error.message,
error.status,
error.response
);
}
}
/**
* Convert ArrayBuffer to base64 string (browser-compatible)
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Map local Options to DocumentUploadOptions
*/
function mapOptions(options?: Options): Partial<DocumentUploadOptions> {
if (!options) {
return {
simplex: false, // duplex by default
color: false, // black and white by default
};
}
const mapped: Partial<DocumentUploadOptions> = {
simplex: options.simplex ?? false,
color: options.color ?? false,
};
if (options.envelope) {
mapped.envelope = options.envelope === 'C4' ? EnvelopeType.C4 : EnvelopeType.DINLANG;
}
if (options.franking) {
switch (options.franking) {
case 'STANDARD_FRANKING':
mapped.franking = FrankingType.STANDARD_FRANKING;
break;
case 'DV_FRANKING':
mapped.franking = FrankingType.DV_FRANKING;
break;
default:
mapped.franking = FrankingType.UNSPECIFIED;
}
}
return mapped;
}
/**
* Convert BinectDocument to our Document interface
*/
function mapDocument(doc: BinectDocument): Document {
// Build letter data if present
let letterData: Document['letter'] = undefined;
if (doc.letter) {
const ld = doc.letter.letterData;
letterData = {
letterType: (doc.letter.letterType || 'LetterData') as 'LetterData' | 'Error',
letterData: ld ? {
recipientAddress: ld.recipientAddress || '',
price: ld.price || { priceBeforeTax: 0, priceAfterTax: 0, unit: 'EUROCENT', taxInPercent: 0 },
international: ld.international || false,
options: {
simplex: ld.options?.simplex,
color: ld.options?.color,
envelope: ld.options?.envelope as 'DINLANG' | 'C4' | undefined,
franking: ld.options?.franking as 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING' | undefined,
},
} : undefined,
errors: doc.letter.errors?.map(e => ({
code: parseInt(e.code, 10) || 0,
text: e.message,
blankText: e.message,
})),
};
}
return {
id: doc.id,
filename: doc.filename || 'document.pdf',
numberOfPages: doc.numberOfPages,
status: {
code: doc.status.code,
text: doc.status.text,
},
documentType: (doc.documentType === 'SERIALLETTER' ? 'SerialLetter' : 'Letter') as 'Letter' | 'SerialLetter',
letter: letterData,
};
}
/**
* Upload PDF to Binect API
*
* Uses the @binect/js library to upload the PDF.
*
* @param pdfData - PDF file as ArrayBuffer
* @param filename - Name of the PDF file
* @param username - Binect username for authentication
* @param password - Binect password for authentication
* @param options - Optional printing options (simplex/duplex, color/bw, etc.)
* @returns Document object with ID and status
*/
export async function uploadPDF(
pdfData: ArrayBuffer,
filename: string,
username: string,
password: string,
options?: Options
): Promise<Document> {
console.log('[Binect API] Uploading PDF to Binect via @binect/js...');
console.log('[Binect API] Filename:', filename);
console.log('[Binect API] PDF size:', pdfData.byteLength, 'bytes');
try {
// Create client with credentials
const client = new BinectClient({
username,
password,
});
// Convert PDF to base64
console.log('[Binect API] Converting PDF to base64...');
const base64Content = arrayBufferToBase64(pdfData);
console.log('[Binect API] Base64 length:', base64Content.length, 'characters');
// Map options
const uploadOptions = mapOptions(options);
console.log('[Binect API] Upload options:', uploadOptions);
// Upload document
const doc = await client.documents.upload({
content: base64Content,
filename,
...uploadOptions,
});
console.log('[Binect API] Upload successful!');
console.log('[Binect API] Document ID:', doc.id);
console.log('[Binect API] Document status:', doc.status);
// Log if document has errors (status ERRONEOUS)
// But still return the document so we can track it and offer delete
if (isErroneous(doc)) {
console.warn('[Binect API] Document is erroneous:', doc.status.text);
const errors = getErrors(doc);
if (errors.length > 0) {
console.warn('[Binect API] Document errors:', errors.map(e => e.message).join('; '));
}
}
return mapDocument(doc);
} catch (error) {
console.error('[Binect API] Upload error:', error);
// Handle @binect/js errors
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
// Map specific status codes
if (error.status === 400) {
throw new BinectAPIError(
'Invalid request. Please check the PDF format and size.',
400
);
}
if (error.status === 413) {
throw new BinectAPIError('File size exceeds limit (12 MB)', 413);
}
throw BinectAPIError.fromBinectError(error);
}
// Already a BinectAPIError
if (error instanceof BinectAPIError) {
throw error;
}
// Network or other errors
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new BinectAPIError(
'Cannot reach Binect API. Please check your internet connection.'
);
}
throw new BinectAPIError(
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Test API connectivity by fetching account information
*
* @param username - Binect username
* @param password - Binect password
* @returns true if authentication successful, false otherwise
*/
export async function testConnection(username: string, password: string): Promise<boolean> {
console.log('[Binect API] Testing connection via @binect/js...');
try {
const client = new BinectClient({
username,
password,
});
// Attempt to get account info
await client.accounts.get();
console.log('[Binect API] Connection successful');
return true;
} catch (error) {
if (error instanceof BinectAuthError) {
console.log('[Binect API] Authentication failed');
return false;
}
if (error instanceof BinectApiError) {
console.warn('[Binect API] API error:', error.message);
// If we get any other API error, credentials might still be valid
// but there's another issue
return false;
}
console.error('[Binect API] Connection test error:', error);
return false;
}
}
/**
* Document status information returned by getDocumentStatus
*/
export interface DocumentStatusInfo {
status: number;
statusText: string;
price?: number;
recipientAddress?: string;
errorDetails?: string; // Error details for erroneous documents
}
/**
* Ship a document (place order for production)
*
* This announces the document for delivery, transitioning it from
* SHIPPABLE to PRODUCTION_QUEUE.
*
* @param documentId - Binect document ID
* @param username - Binect username
* @param password - Binect password
* @returns Updated status info
*/
export async function shipDocument(
documentId: number,
username: string,
password: string
): Promise<DocumentStatusInfo> {
console.log('[Binect API] Shipping document:', documentId);
try {
const client = new BinectClient({
username,
password,
});
// Send the document for production
const sending = await client.sendings.send(String(documentId));
console.log('[Binect API] Document shipped successfully');
console.log('[Binect API] New status:', sending.status);
return {
status: sending.status,
statusText: getStatusDescription(sending.status),
price: sending.price,
};
} catch (error) {
console.error('[Binect API] Ship error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
// Check for insufficient balance (error code 2330)
if (error.message.includes('2330') || error.message.includes('balance')) {
throw new BinectAPIError('Insufficient account balance', 402);
}
throw BinectAPIError.fromBinectError(error);
}
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Failed to ship document: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Get current status of a document
*
* @param documentId - Binect document ID
* @param username - Binect username
* @param password - Binect password
* @returns Current status info
*/
export async function getDocumentStatus(
documentId: number,
username: string,
password: string
): Promise<DocumentStatusInfo> {
console.log('[Binect API] Getting document status:', documentId);
try {
const client = new BinectClient({
username,
password,
});
// Fetch document details
const doc = await client.documents.get(String(documentId));
console.log('[Binect API] Document status:', doc.status);
// Extract price and recipient if available
let price: number | undefined;
let recipientAddress: string | undefined;
let errorDetails: string | undefined;
if (doc.letter?.letterData) {
price = doc.letter.letterData.price?.priceAfterTax;
recipientAddress = doc.letter.letterData.recipientAddress;
}
// Extract error details for erroneous documents
if (isErroneous(doc)) {
const errors = getErrors(doc);
if (errors.length > 0) {
errorDetails = errors.map(e => e.message).join('; ');
console.log('[Binect API] Document errors:', errorDetails);
}
}
return {
status: doc.status.code,
statusText: doc.status.text || getStatusDescription(doc.status.code),
price,
recipientAddress,
errorDetails,
};
} catch (error) {
console.error('[Binect API] Get status error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
// Check for 404 - document not found (deleted on server)
if (error.status === 404) {
throw new BinectAPIError('Document not found on server', 404);
}
throw BinectAPIError.fromBinectError(error);
}
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Failed to get document status: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Server document info for sync
*/
export interface ServerDocument {
id: number;
filename: string;
status: number;
statusText: string;
price?: number;
recipientAddress?: string;
errorDetails?: string;
}
/**
* List all shippable documents from the server
*
* @param username - Binect username
* @param password - Binect password
* @returns Array of server documents
*/
export async function listServerDocuments(
username: string,
password: string
): Promise<ServerDocument[]> {
console.log('[Binect API] Listing server documents...');
try {
const client = new BinectClient({
username,
password,
});
// Get shippable documents (status 2)
const shippableResponse = await client.documents.list();
const shippable = shippableResponse.items || [];
// Get erroneous documents (status 7)
const errorsResponse = await client.documents.listErrors();
const erroneous = errorsResponse.items || [];
// Combine and map to our format
const allDocs = [...shippable, ...erroneous];
console.log('[Binect API] Found', allDocs.length, 'documents on server (', shippable.length, 'shippable,', erroneous.length, 'erroneous)');
return allDocs.map(doc => {
let errorDetails: string | undefined;
if (isErroneous(doc)) {
const errors = getErrors(doc);
if (errors.length > 0) {
errorDetails = errors.map(e => e.message).join('; ');
}
}
return {
id: doc.id,
filename: doc.filename || 'document.pdf',
status: doc.status.code,
statusText: doc.status.text || getStatusDescription(doc.status.code),
price: doc.letter?.letterData?.price?.priceAfterTax,
recipientAddress: doc.letter?.letterData?.recipientAddress,
errorDetails,
};
});
} catch (error) {
console.error('[Binect API] List documents error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
throw BinectAPIError.fromBinectError(error);
}
throw new BinectAPIError(
`Failed to list documents: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Delete a document from the server
*
* @param documentId - Binect document ID
* @param username - Binect username
* @param password - Binect password
*/
export async function deleteDocument(
documentId: number,
username: string,
password: string
): Promise<void> {
console.log('[Binect API] Deleting document:', documentId);
try {
const client = new BinectClient({
username,
password,
});
await client.documents.delete(String(documentId));
console.log('[Binect API] Document deleted successfully');
} catch (error) {
console.error('[Binect API] Delete error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
if (error.status === 404) {
// Already deleted, treat as success
console.log('[Binect API] Document already deleted (404)');
return;
}
throw BinectAPIError.fromBinectError(error);
}
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Failed to delete document: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// Re-export DocumentStatus enum for use in other modules
export { DocumentStatus };