Add Binect SDK implementation, Explorer, and test suite

SDK (@binect/js):
- BinectClient with domain sub-clients (documents, sendings, accounts,
  attachments, invoices)
- HTTP Basic Auth, native fetch only (no runtime dependencies)
- TypeScript types matching Binect API vocabulary
- Status predicates and polling helpers in helpers.ts
- Structured error handling (BinectApiError, BinectAuthError)

Explorer:
- Standalone browser-based API explorer (explorer/index.html)
- Interactive testing without code

Tests:
- Unit tests for client, types, errors, helpers, http
- E2E tests for upload/delete and send/cancel workflows

Also includes:
- Architecture Decision Records (ADRs)
- Example DIN 5008 letter PDFs for testing
- API specification research notes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 23:10:34 +01:00
parent 20462b48b3
commit b9aebb42f1
34 changed files with 6499 additions and 20 deletions

75
src/client.ts Normal file
View File

@@ -0,0 +1,75 @@
import { HttpClient, DEFAULT_BASE_URL } from './http.js';
import { DocumentsClient } from './clients/documents.js';
import { AttachmentsClient } from './clients/attachments.js';
import { SendingsClient } from './clients/sendings.js';
import { AccountsClient } from './clients/accounts.js';
import { InvoicesClient } from './clients/invoices.js';
import type { BinectClientConfig } from './types.js';
/**
* Main client for interacting with the Binect API.
*
* Provides access to all API domains through sub-clients:
* - documents: Upload, manage, and preview documents
* - attachments: Manage standalone attachments
* - sendings: Announce, send, and track mail dispatch
* - accounts: Access account info, options, and journals
* - invoices: List and download invoices
*
* @example
* ```typescript
* const client = new BinectClient({
* username: 'user@example.com',
* password: 'your-password',
* });
*
* // Upload a document
* const doc = await client.documents.upload({
* content: base64PdfContent,
* color: false,
* duplex: true,
* });
*
* // Send when ready
* if (doc.status === DocumentStatus.SHIPPABLE) {
* await client.sendings.send(doc.documentId);
* }
* ```
*/
export class BinectClient {
private readonly http: HttpClient;
/** Client for document operations */
public readonly documents: DocumentsClient;
/** Client for attachment operations */
public readonly attachments: AttachmentsClient;
/** Client for sending/shipment operations */
public readonly sendings: SendingsClient;
/** Client for account operations */
public readonly accounts: AccountsClient;
/** Client for invoice operations */
public readonly invoices: InvoicesClient;
/**
* Creates a new Binect API client.
*
* @param config - Client configuration including credentials
*/
constructor(config: BinectClientConfig) {
this.http = new HttpClient({
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
username: config.username,
password: config.password,
});
this.documents = new DocumentsClient(this.http);
this.attachments = new AttachmentsClient(this.http);
this.sendings = new SendingsClient(this.http);
this.accounts = new AccountsClient(this.http);
this.invoices = new InvoicesClient(this.http);
}
}

129
src/clients/accounts.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { HttpClient } from '../http.js';
import type {
AccountInfo,
PersonalData,
PersonalDataUpdate,
AccountPrintOptions,
Coworker,
JournalEntry,
ListResponse,
} from '../types.js';
/**
* Client for account-related API operations.
* Handles account info, personal data, print options, and journal access.
*/
export class AccountsClient {
constructor(private readonly http: HttpClient) {}
/**
* Get account balance and credit information.
* GET /accounts
*
* @returns Account info including credit balance
*/
async get(): Promise<AccountInfo> {
return this.http.request<AccountInfo>({
method: 'GET',
path: '/accounts',
});
}
/**
* Get personal data for the account.
* GET /accounts/personaldata
*
* @returns Personal data including contact information
*/
async getPersonalData(): Promise<PersonalData> {
return this.http.request<PersonalData>({
method: 'GET',
path: '/accounts/personaldata',
});
}
/**
* Update personal data for the account.
* PATCH /accounts/personaldata
*
* @param data - Fields to update
* @returns Updated personal data
*/
async updatePersonalData(data: PersonalDataUpdate): Promise<PersonalData> {
return this.http.request<PersonalData>({
method: 'PATCH',
path: '/accounts/personaldata',
body: data,
});
}
/**
* Get default print options for the account.
* GET /accounts/options
*
* @returns Default print options
*/
async getOptions(): Promise<AccountPrintOptions> {
return this.http.request<AccountPrintOptions>({
method: 'GET',
path: '/accounts/options',
});
}
/**
* Update default print options for the account.
* PUT /accounts/options
*
* @param options - New default print options
* @returns Updated print options
*/
async updateOptions(options: AccountPrintOptions): Promise<AccountPrintOptions> {
return this.http.request<AccountPrintOptions>({
method: 'PUT',
path: '/accounts/options',
body: options,
});
}
/**
* List coworkers associated with this account.
* GET /accounts/coworkers
*
* @returns List of coworkers
*/
async getCoworkers(): Promise<ListResponse<Coworker>> {
return this.http.request<ListResponse<Coworker>>({
method: 'GET',
path: '/accounts/coworkers',
});
}
/**
* Get journal/transaction entries for a coworker in a specific month.
* GET /accounts/coworkers/{debitornumber}/journal/{month}
*
* @param debitornumber - The coworker's debitor number
* @param month - Month in YYYY-MM format
* @returns List of journal entries
*/
async getCoworkerJournal(debitornumber: string, month: string): Promise<ListResponse<JournalEntry>> {
return this.http.request<ListResponse<JournalEntry>>({
method: 'GET',
path: `/accounts/coworkers/${encodeURIComponent(debitornumber)}/journal/${encodeURIComponent(month)}`,
});
}
/**
* Get journal/transaction entries for the account in a specific month.
* GET /accounts/journal/{month}
*
* @param month - Month in YYYY-MM format
* @returns List of journal entries
*/
async getJournal(month: string): Promise<ListResponse<JournalEntry>> {
return this.http.request<ListResponse<JournalEntry>>({
method: 'GET',
path: `/accounts/journal/${encodeURIComponent(month)}`,
});
}
}

110
src/clients/attachments.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { HttpClient } from '../http.js';
import type { Attachment, AttachmentUploadOptions, ListResponse, PaginationOptions } from '../types.js';
/**
* Client for attachment-related API operations.
* Handles standalone attachment upload, retrieval, and management.
*/
export class AttachmentsClient {
constructor(private readonly http: HttpClient) {}
/**
* Upload a new attachment.
* POST /attachments
*
* @param options - Attachment upload options including base64-encoded PDF content
* @returns The created attachment
*/
async upload(options: AttachmentUploadOptions): Promise<Attachment> {
return this.http.request<Attachment>({
method: 'POST',
path: '/attachments',
body: options,
});
}
/**
* List all attachments.
* GET /attachments
*
* @param pagination - Optional pagination parameters
* @returns List of attachments
*/
async list(pagination?: PaginationOptions): Promise<ListResponse<Attachment>> {
return this.http.request<ListResponse<Attachment>>({
method: 'GET',
path: '/attachments',
query: pagination,
});
}
/**
* Get a specific attachment by ID.
* GET /attachments/{attachmentID}
*
* @param attachmentId - The attachment ID
* @returns The attachment details
*/
async get(attachmentId: string): Promise<Attachment> {
return this.http.request<Attachment>({
method: 'GET',
path: `/attachments/${encodeURIComponent(attachmentId)}`,
});
}
/**
* Delete an attachment.
* DELETE /attachments/{attachmentID}
*
* @param attachmentId - The attachment ID to delete
*/
async delete(attachmentId: string): Promise<void> {
await this.http.request<void>({
method: 'DELETE',
path: `/attachments/${encodeURIComponent(attachmentId)}`,
});
}
/**
* Get PDF preview of an attachment.
* GET /attachments/{attachmentID}/pdf
*
* @param attachmentId - The attachment ID
* @returns Response containing PDF data
*/
async getPdf(attachmentId: string): Promise<Response> {
return this.http.requestRaw({
method: 'GET',
path: `/attachments/${encodeURIComponent(attachmentId)}/pdf`,
});
}
/**
* Get documents that have this attachment.
* GET /attachments/{attachmentID}/documents
*
* @param attachmentId - The attachment ID
* @returns Array of document IDs
*/
async getDocuments(attachmentId: string): Promise<string[]> {
return this.http.request<string[]>({
method: 'GET',
path: `/attachments/${encodeURIComponent(attachmentId)}/documents`,
});
}
/**
* Attach this attachment to multiple documents.
* PATCH /attachments/{attachmentID}/documents
*
* @param attachmentId - The attachment ID
* @param documentIds - Array of document IDs to attach to
*/
async attachToDocuments(attachmentId: string, documentIds: string[]): Promise<void> {
await this.http.request<void>({
method: 'PATCH',
path: `/attachments/${encodeURIComponent(attachmentId)}/documents`,
body: { documentIds },
});
}
}

352
src/clients/documents.ts Normal file
View File

@@ -0,0 +1,352 @@
import type { HttpClient } from '../http.js';
import type {
Document,
DocumentUploadOptions,
DocumentUploadRequestBody,
DocumentTransformation,
CoverPageOptions,
DocumentAttribute,
ListResponse,
PaginationOptions,
} from '../types.js';
/**
* Client for document-related API operations.
* Handles document upload, retrieval, modification, and preview.
*/
export class DocumentsClient {
constructor(private readonly http: HttpClient) {}
/**
* Upload a new document (letter or serial letter).
* POST /documents
*
* @param options - Document upload options including base64-encoded PDF content
* @returns The created document with validation results
*/
async upload(options: DocumentUploadOptions): Promise<Document> {
// Transform user-friendly options to API request body format
const requestBody: DocumentUploadRequestBody = {
content: {
filename: options.filename,
content: options.content,
},
};
// Add options if any are specified
if (options.simplex !== undefined || options.color !== undefined ||
options.envelope !== undefined || options.franking !== undefined ||
options.productionCountry !== undefined) {
requestBody.options = {
simplex: options.simplex,
color: options.color,
envelope: options.envelope,
franking: options.franking,
productionCountry: options.productionCountry,
};
}
// Add attributes if specified
if (options.attributes) {
requestBody.attributes = options.attributes;
}
// Add split params if specified
if (options.splitToken !== undefined || options.pagesPerLetter !== undefined) {
requestBody.splitParams = {
splitToken: options.splitToken,
splitAfterNumberOfPages: options.pagesPerLetter,
};
}
// Add response format if specified
if (options.responseFormat !== undefined) {
requestBody.responseFormat = options.responseFormat;
}
return this.http.request<Document>({
method: 'POST',
path: '/documents',
body: requestBody,
});
}
/**
* List all shippable documents (status 2).
* GET /documents
*
* @param pagination - Optional pagination parameters
* @returns List of shippable documents
*/
async list(pagination?: PaginationOptions): Promise<ListResponse<Document>> {
return this.http.request<ListResponse<Document>>({
method: 'GET',
path: '/documents',
query: pagination,
});
}
/**
* List all documents with errors (status 7).
* GET /documents/errors
*
* @param pagination - Optional pagination parameters
* @returns List of erroneous documents
*/
async listErrors(pagination?: PaginationOptions): Promise<ListResponse<Document>> {
return this.http.request<ListResponse<Document>>({
method: 'GET',
path: '/documents/errors',
query: pagination,
});
}
/**
* Get a specific document by ID.
* GET /documents/{documentID}
*
* @param documentId - The document ID
* @returns The document details
*/
async get(documentId: string): Promise<Document> {
return this.http.request<Document>({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}`,
});
}
/**
* Delete a document.
* DELETE /documents/{documentID}
*
* @param documentId - The document ID to delete
*/
async delete(documentId: string): Promise<void> {
await this.http.request<void>({
method: 'DELETE',
path: `/documents/${encodeURIComponent(documentId)}`,
});
}
/**
* Get all attributes for a document.
* GET /documents/{documentID}/attributes
*
* @param documentId - The document ID
* @returns Key-value attributes
*/
async getAttributes(documentId: string): Promise<Record<string, string>> {
return this.http.request<Record<string, string>>({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}/attributes`,
});
}
/**
* Set attributes for a document.
* POST /documents/{documentID}/attributes
*
* @param documentId - The document ID
* @param attributes - Array of key-value attributes to set
*/
async setAttributes(documentId: string, attributes: DocumentAttribute[]): Promise<void> {
await this.http.request<void>({
method: 'POST',
path: `/documents/${encodeURIComponent(documentId)}/attributes`,
body: attributes,
});
}
/**
* Get a specific attribute value.
* GET /documents/{documentID}/attributes/{key}
*
* @param documentId - The document ID
* @param key - The attribute key
* @returns The attribute value
*/
async getAttribute(documentId: string, key: string): Promise<string> {
return this.http.request<string>({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}/attributes/${encodeURIComponent(key)}`,
});
}
/**
* Update a specific attribute value.
* PUT /documents/{documentID}/attributes/{key}
*
* @param documentId - The document ID
* @param key - The attribute key
* @param value - The new attribute value
*/
async updateAttribute(documentId: string, key: string, value: string): Promise<void> {
await this.http.request<void>({
method: 'PUT',
path: `/documents/${encodeURIComponent(documentId)}/attributes/${encodeURIComponent(key)}`,
body: { value },
});
}
/**
* Delete a specific attribute.
* DELETE /documents/{documentID}/attributes/{key}
*
* @param documentId - The document ID
* @param key - The attribute key to delete
*/
async deleteAttribute(documentId: string, key: string): Promise<void> {
await this.http.request<void>({
method: 'DELETE',
path: `/documents/${encodeURIComponent(documentId)}/attributes/${encodeURIComponent(key)}`,
});
}
/**
* Apply transformations (scaling/offset) to a document.
* PUT /documents/{documentID}/transformations
*
* @param documentId - The document ID
* @param transformation - Transformation parameters
* @returns The updated document
*/
async applyTransformation(
documentId: string,
transformation: DocumentTransformation
): Promise<Document> {
return this.http.request<Document>({
method: 'PUT',
path: `/documents/${encodeURIComponent(documentId)}/transformations`,
body: transformation,
});
}
/**
* Revert transformations to original document.
* DELETE /documents/{documentID}/transformations
*
* @param documentId - The document ID
* @returns The updated document
*/
async revertTransformation(documentId: string): Promise<Document> {
return this.http.request<Document>({
method: 'DELETE',
path: `/documents/${encodeURIComponent(documentId)}/transformations`,
});
}
/**
* Add a cover page to a document.
* PUT /documents/{documentID}/coverpage
*
* @param documentId - The document ID
* @param options - Cover page options with base64-encoded PDF
* @returns The updated document
*/
async addCoverPage(documentId: string, options: CoverPageOptions): Promise<Document> {
return this.http.request<Document>({
method: 'PUT',
path: `/documents/${encodeURIComponent(documentId)}/coverpage`,
body: options,
});
}
/**
* Remove the cover page from a document.
* DELETE /documents/{documentID}/coverpage
*
* @param documentId - The document ID
* @returns The updated document
*/
async removeCoverPage(documentId: string): Promise<Document> {
return this.http.request<Document>({
method: 'DELETE',
path: `/documents/${encodeURIComponent(documentId)}/coverpage`,
});
}
/**
* Get PDF preview of a document.
* GET /documents/{documentID}/pdf
*
* @param documentId - The document ID
* @returns Response containing PDF data
*/
async getPdf(documentId: string): Promise<Response> {
return this.http.requestRaw({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}/pdf`,
});
}
/**
* Get PNG preview of a document (first page).
* GET /documents/{documentID}/png
*
* @param documentId - The document ID
* @returns Response containing PNG data
*/
async getPng(documentId: string): Promise<Response> {
return this.http.requestRaw({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}/png`,
});
}
/**
* Get attachments for a document.
* GET /documents/{documentID}/attachments
*
* @param documentId - The document ID
* @returns Array of attachment IDs
*/
async getAttachments(documentId: string): Promise<string[]> {
return this.http.request<string[]>({
method: 'GET',
path: `/documents/${encodeURIComponent(documentId)}/attachments`,
});
}
/**
* Add an attachment to a document.
* POST /documents/{documentID}/attachments
*
* @param documentId - The document ID
* @param attachmentId - The attachment ID to add
*/
async addAttachment(documentId: string, attachmentId: string): Promise<void> {
await this.http.request<void>({
method: 'POST',
path: `/documents/${encodeURIComponent(documentId)}/attachments`,
body: { attachmentId },
});
}
/**
* Update attachments for a document (replace all).
* PATCH /documents/{documentID}/attachments
*
* @param documentId - The document ID
* @param attachmentIds - Array of attachment IDs
*/
async updateAttachments(documentId: string, attachmentIds: string[]): Promise<void> {
await this.http.request<void>({
method: 'PATCH',
path: `/documents/${encodeURIComponent(documentId)}/attachments`,
body: { attachmentIds },
});
}
/**
* Remove all attachments from a document.
* DELETE /documents/{documentID}/attachments
*
* @param documentId - The document ID
*/
async removeAttachments(documentId: string): Promise<void> {
await this.http.request<void>({
method: 'DELETE',
path: `/documents/${encodeURIComponent(documentId)}/attachments`,
});
}
}

5
src/clients/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { DocumentsClient } from './documents.js';
export { AttachmentsClient } from './attachments.js';
export { SendingsClient } from './sendings.js';
export { AccountsClient } from './accounts.js';
export { InvoicesClient } from './invoices.js';

53
src/clients/invoices.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { HttpClient } from '../http.js';
import type { Invoice, InvoiceDetail, ListResponse, PaginationOptions } from '../types.js';
/**
* Client for invoice-related API operations.
* Handles invoice listing, details, and PDF download.
*/
export class InvoicesClient {
constructor(private readonly http: HttpClient) {}
/**
* List all invoices.
* GET /invoices
*
* @param pagination - Optional pagination parameters
* @returns List of invoices
*/
async list(pagination?: PaginationOptions): Promise<ListResponse<Invoice>> {
return this.http.request<ListResponse<Invoice>>({
method: 'GET',
path: '/invoices',
query: pagination,
});
}
/**
* Get invoice details including transactions.
* GET /invoices/{invoiceNumber}
*
* @param invoiceNumber - The invoice number
* @returns Invoice details with transaction entries
*/
async get(invoiceNumber: string): Promise<InvoiceDetail> {
return this.http.request<InvoiceDetail>({
method: 'GET',
path: `/invoices/${encodeURIComponent(invoiceNumber)}`,
});
}
/**
* Download invoice as PDF.
* GET /invoices/{invoiceNumber}/pdf
*
* @param invoiceNumber - The invoice number
* @returns Response containing PDF data
*/
async getPdf(invoiceNumber: string): Promise<Response> {
return this.http.requestRaw({
method: 'GET',
path: `/invoices/${encodeURIComponent(invoiceNumber)}/pdf`,
});
}
}

153
src/clients/sendings.ts Normal file
View File

@@ -0,0 +1,153 @@
import type { HttpClient } from '../http.js';
import type {
Sending,
Document,
DocumentUploadAndSendOptions,
ListResponse,
PaginationOptions,
BatchStatusResponse,
DocumentStatus,
} from '../types.js';
/**
* Client for sending/shipment-related API operations.
* Handles document dispatch, cancellation, and status tracking.
*/
export class SendingsClient {
constructor(private readonly http: HttpClient) {}
/**
* Announce multiple documents for delivery.
* POST /sendings
*
* @param documentIds - Array of document IDs to announce (as numbers or numeric strings)
* @returns Array of sending confirmations
*/
async announce(documentIds: (string | number)[]): Promise<Sending[]> {
// API expects a raw array of integers
const ids = documentIds.map((id) => (typeof id === 'string' ? parseInt(id, 10) : id));
return this.http.request<Sending[]>({
method: 'POST',
path: '/sendings',
body: ids,
});
}
/**
* List all sendings/shipments (statuses 3-7).
* GET /sendings
*
* @param pagination - Optional pagination parameters
* @returns List of sendings
*/
async list(pagination?: PaginationOptions): Promise<ListResponse<Sending>> {
return this.http.request<ListResponse<Sending>>({
method: 'GET',
path: '/sendings',
query: pagination,
});
}
/**
* Cancel multiple announced sendings.
* PUT /sendings
*
* @param documentIds - Array of document IDs to cancel (as numbers or numeric strings)
* @returns Array of updated sendings
*/
async cancelMultiple(documentIds: (string | number)[]): Promise<Sending[]> {
// API expects a raw array of integers
const ids = documentIds.map((id) => (typeof id === 'string' ? parseInt(id, 10) : id));
return this.http.request<Sending[]>({
method: 'PUT',
path: '/sendings',
body: ids,
});
}
/**
* Trigger sending for a single document.
* POST /sendings/{documentID}
*
* @param documentId - The document ID to send
* @returns The sending confirmation
*/
async send(documentId: string): Promise<Sending> {
return this.http.request<Sending>({
method: 'POST',
path: `/sendings/${encodeURIComponent(documentId)}`,
});
}
/**
* Get sending status for a specific document.
* GET /sendings/{documentID}
*
* @param documentId - The document ID
* @returns The sending details
*/
async get(documentId: string): Promise<Sending> {
return this.http.request<Sending>({
method: 'GET',
path: `/sendings/${encodeURIComponent(documentId)}`,
});
}
/**
* Cancel a specific sending.
* PUT /sendings/{documentID}
*
* @param documentId - The document ID to cancel
* @returns The updated sending
*/
async cancel(documentId: string): Promise<Sending> {
return this.http.request<Sending>({
method: 'PUT',
path: `/sendings/${encodeURIComponent(documentId)}`,
});
}
/**
* Delete a sending record.
* DELETE /sendings/{documentID}
*
* @param documentId - The document ID to delete
*/
async delete(documentId: string): Promise<void> {
await this.http.request<void>({
method: 'DELETE',
path: `/sendings/${encodeURIComponent(documentId)}`,
});
}
/**
* Upload a document and immediately send it.
* POST /sendings/document
*
* @param options - Document upload options
* @returns The created and sent document
*/
async uploadAndSend(options: DocumentUploadAndSendOptions): Promise<Document> {
return this.http.request<Document>({
method: 'POST',
path: '/sendings/document',
body: options,
});
}
/**
* Get batch status for multiple documents.
* GET /sendings/status
*
* @param documentIds - Array of document IDs to check
* @returns Map of document IDs to their statuses
*/
async getStatus(documentIds: string[]): Promise<Record<string, DocumentStatus>> {
const response = await this.http.request<BatchStatusResponse>({
method: 'GET',
path: '/sendings/status',
query: { documentIds: documentIds.join(',') },
});
return response.statuses;
}
}

71
src/errors.ts Normal file
View File

@@ -0,0 +1,71 @@
import type { ApiErrorResponse } from './types.js';
/**
* Error thrown when the Binect API returns a non-success response.
* Preserves HTTP status, endpoint, and parsed response body.
*/
export class BinectApiError extends Error {
/** HTTP status code */
public readonly status: number;
/** API endpoint that was called */
public readonly endpoint: string;
/** HTTP method used */
public readonly method: string;
/** Parsed error response from API (when available) */
public readonly response: ApiErrorResponse | null;
constructor(
message: string,
status: number,
endpoint: string,
method: string,
response: ApiErrorResponse | null = null
) {
super(message);
this.name = 'BinectApiError';
this.status = status;
this.endpoint = endpoint;
this.method = method;
this.response = response;
// Maintains proper stack trace for where error was thrown (V8 engines)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, BinectApiError);
}
}
/**
* Returns a detailed string representation of the error
*/
toDetailedString(): string {
const parts = [
`BinectApiError: ${this.message}`,
` Status: ${this.status}`,
` Endpoint: ${this.method} ${this.endpoint}`,
];
if (this.response) {
if (this.response.error) {
parts.push(` Error: ${this.response.error}`);
}
if (this.response.message) {
parts.push(` Message: ${this.response.message}`);
}
if (this.response.details && this.response.details.length > 0) {
parts.push(` Details: ${this.response.details.join(', ')}`);
}
}
return parts.join('\n');
}
}
/**
* Error thrown when credentials are invalid or missing
*/
export class BinectAuthError extends BinectApiError {
constructor(endpoint: string, method: string, response: ApiErrorResponse | null = null) {
super('Authentication failed: Invalid credentials', 401, endpoint, method, response);
this.name = 'BinectAuthError';
}
}

274
src/helpers.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Convenience Layer - Optional Helpers
*
* These helpers are purely additive and do not replace core API methods.
* They provide convenient predicates and utilities for common operations.
*/
import { DocumentStatus, type Document, type Sending, type ValidationMessage } from './types.js';
/**
* Helper to get status code from document or sending
*/
function getStatusCode(doc: Document | Sending): DocumentStatus {
// Document has status.code, Sending might have status directly
if (typeof doc.status === 'object' && 'code' in doc.status) {
return doc.status.code;
}
return doc.status as unknown as DocumentStatus;
}
// ============================================================================
// Status Predicates
// ============================================================================
/**
* Check if a document is shippable (status 2).
*/
export function isShippable(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.SHIPPABLE;
}
/**
* Check if a document has errors (status 7).
*/
export function isErroneous(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.ERRONEOUS;
}
/**
* Check if a document is still being prepared (status 1).
*/
export function isInPreparation(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.IN_PREPARATION;
}
/**
* Check if a document is in the production queue (status 3).
*/
export function isInProductionQueue(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.PRODUCTION_QUEUE;
}
/**
* Check if a document is currently being printed (status 4).
*/
export function isPrinting(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.PRINTING;
}
/**
* Check if a document has been sent (status 5).
*/
export function isSent(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.SENT;
}
/**
* Check if a document was canceled (status 6).
*/
export function isCanceled(doc: Document | Sending): boolean {
return getStatusCode(doc) === DocumentStatus.CANCELED;
}
/**
* Check if a document is in a terminal state (sent, canceled, or erroneous).
*/
export function isTerminal(doc: Document | Sending): boolean {
const status = getStatusCode(doc);
return (
status === DocumentStatus.SENT ||
status === DocumentStatus.CANCELED ||
status === DocumentStatus.ERRONEOUS
);
}
/**
* Check if a document can still be canceled (in queue or printing).
*/
export function isCancelable(doc: Document | Sending): boolean {
const status = getStatusCode(doc);
return (
status === DocumentStatus.PRODUCTION_QUEUE ||
status === DocumentStatus.PRINTING
);
}
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Get all validation messages from a document.
*/
function getValidationMessages(doc: Document): ValidationMessage[] {
return doc.letter?.errors ?? [];
}
/**
* Extract error messages from validation results.
*/
export function getErrors(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'ERROR');
}
/**
* Extract warning messages from validation results.
*/
export function getWarnings(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'WARNING');
}
/**
* Extract info messages from validation results.
*/
export function getInfoMessages(doc: Document): ValidationMessage[] {
return getValidationMessages(doc).filter((m: ValidationMessage) => m.type === 'INFO');
}
/**
* Check if document has any validation errors.
*/
export function hasErrors(doc: Document): boolean {
return getErrors(doc).length > 0;
}
/**
* Check if document has any validation warnings.
*/
export function hasWarnings(doc: Document): boolean {
return getWarnings(doc).length > 0;
}
// ============================================================================
// Status Description
// ============================================================================
/**
* Get human-readable description of document status.
*/
export function getStatusDescription(status: DocumentStatus): string {
switch (status) {
case DocumentStatus.IN_PREPARATION:
return 'In preparation';
case DocumentStatus.SHIPPABLE:
return 'Ready to ship';
case DocumentStatus.PRODUCTION_QUEUE:
return 'In production queue';
case DocumentStatus.PRINTING:
return 'Printing';
case DocumentStatus.SENT:
return 'Sent';
case DocumentStatus.CANCELED:
return 'Canceled';
case DocumentStatus.ERRONEOUS:
return 'Has errors';
default:
return 'Unknown status';
}
}
// ============================================================================
// Base64 Utilities
// ============================================================================
/**
* Encode a file/blob to base64 string (browser environment).
* Returns a promise that resolves to the base64-encoded content.
*/
export function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:application/pdf;base64,")
const base64 = result.split(',')[1];
if (base64) {
resolve(base64);
} else {
reject(new Error('Failed to extract base64 content'));
}
};
reader.onerror = (): void => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* Encode a Buffer to base64 string (Node.js environment).
*/
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64');
}
// ============================================================================
// Polling Utilities (Opt-in, no default behavior)
// ============================================================================
/**
* Options for polling operations.
*/
export interface PollOptions {
/** Interval between polls in milliseconds (default: 2000) */
intervalMs?: number;
/** Maximum number of poll attempts (default: 30) */
maxAttempts?: number;
/** Abort signal for cancellation */
signal?: AbortSignal;
}
/**
* Poll until a condition is met.
* This is an opt-in utility - no automatic polling occurs.
*
* @param fn - Function to poll that returns the current state
* @param condition - Condition to check against the result
* @param options - Polling options
* @returns The final result when condition is met
* @throws Error if max attempts exceeded or aborted
*/
export async function pollUntil<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
options: PollOptions = {}
): Promise<T> {
const intervalMs = options.intervalMs ?? 2000;
const maxAttempts = options.maxAttempts ?? 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (options.signal?.aborted) {
throw new Error('Polling aborted');
}
const result = await fn();
if (condition(result)) {
return result;
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
throw new Error(`Polling exceeded maximum attempts (${maxAttempts})`);
}
/**
* Wait for a document to reach a shippable state.
* Convenience wrapper around pollUntil.
*
* @param getDocument - Function that fetches the document
* @param options - Polling options
* @returns The document when it becomes shippable or erroneous
*/
export async function waitForShippable(
getDocument: () => Promise<Document>,
options: PollOptions = {}
): Promise<Document> {
return pollUntil(
getDocument,
(doc) => isShippable(doc) || isErroneous(doc),
options
);
}

183
src/http.ts Normal file
View File

@@ -0,0 +1,183 @@
import { BinectApiError, BinectAuthError } from './errors.js';
import type { ApiErrorResponse } from './types.js';
/**
* Default base URL for the Binect API
*/
export const DEFAULT_BASE_URL = 'https://app.binect.de/binectapi/v1';
/**
* Encodes credentials for HTTP Basic Authentication.
* Works in both browser and Node.js environments.
*/
export function encodeBasicAuth(username: string, password: string): string {
const credentials = `${username}:${password}`;
// Use Buffer in Node.js, btoa in browser
if (typeof Buffer !== 'undefined') {
return Buffer.from(credentials, 'utf-8').toString('base64');
}
// Browser environment
return btoa(credentials);
}
/**
* HTTP client configuration
*/
export interface HttpClientConfig {
baseUrl: string;
username: string;
password: string;
}
/**
* HTTP request options
*/
export interface RequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
body?: unknown;
query?: Record<string, string | number | undefined>;
}
/**
* Low-level HTTP client for Binect API requests.
* Handles authentication, request formatting, and error parsing.
*/
export class HttpClient {
private readonly baseUrl: string;
private readonly authHeader: string;
constructor(config: HttpClientConfig) {
this.baseUrl = config.baseUrl;
this.authHeader = `Basic ${encodeBasicAuth(config.username, config.password)}`;
}
/**
* Makes an HTTP request to the Binect API
*/
async request<T>(options: RequestOptions): Promise<T> {
const url = this.buildUrl(options.path, options.query);
const headers: Record<string, string> = {
Authorization: this.authHeader,
Accept: 'application/json',
};
const init: RequestInit = {
method: options.method,
headers,
};
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}
const response = await fetch(url, init);
if (!response.ok) {
await this.handleErrorResponse(response, options.path, options.method);
}
// Handle empty responses (204 No Content, etc.)
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
// Some endpoints may return non-JSON even with JSON content-type
// Check if response looks like JSON before parsing
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return undefined as T;
}
return JSON.parse(text) as T;
}
/**
* Makes a request and returns raw response (for binary data like PDFs)
*/
async requestRaw(options: RequestOptions): Promise<Response> {
const url = this.buildUrl(options.path, options.query);
const headers: Record<string, string> = {
Authorization: this.authHeader,
};
const init: RequestInit = {
method: options.method,
headers,
};
const response = await fetch(url, init);
if (!response.ok) {
await this.handleErrorResponse(response, options.path, options.method);
}
return response;
}
/**
* Builds the full URL with query parameters
*/
private buildUrl(path: string, query?: Record<string, string | number | undefined>): string {
// Ensure proper URL construction by concatenating base and path
// Remove trailing slash from base and leading slash from path to avoid double slashes
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${base}${cleanPath}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
/**
* Handles error responses from the API
*/
private async handleErrorResponse(
response: Response,
endpoint: string,
method: string
): Promise<never> {
let errorResponse: ApiErrorResponse | null = null;
let rawText = '';
try {
rawText = await response.text();
if (rawText) {
// Try to parse as JSON
const trimmed = rawText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
errorResponse = JSON.parse(rawText) as ApiErrorResponse;
}
}
} catch {
// JSON parsing failed - keep the raw text for the error message
}
if (response.status === 401) {
throw new BinectAuthError(endpoint, method, errorResponse);
}
// Use structured error message if available, otherwise use raw text or generic message
const message = errorResponse?.message ?? errorResponse?.error ?? (rawText || `HTTP ${response.status} error`);
throw new BinectApiError(message, response.status, endpoint, method, errorResponse);
}
}

95
src/index.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Binect-JS SDK
*
* A JavaScript/TypeScript wrapper for the Binect API
* to send PDF documents as physical mail.
*
* @packageDocumentation
*/
// Main client
export { BinectClient } from './client.js';
// Sub-clients (for advanced usage)
export {
DocumentsClient,
AttachmentsClient,
SendingsClient,
AccountsClient,
InvoicesClient,
} from './clients/index.js';
// Types
export {
// Enums
DocumentStatus,
EnvelopeType,
FrankingType,
ProductionCountry,
ResponseFormat,
// Request types
type DocumentUploadOptions,
type DocumentUploadAndSendOptions,
type DocumentTransformation,
type CoverPageOptions,
type DocumentAttribute,
type PaginationOptions,
type SendingAnnounceOptions,
type SendingCancelOptions,
type PersonalDataUpdate,
type AccountPrintOptions,
type AttachmentUploadOptions,
// Response types
type ValidationMessage,
type ExtractedAddress,
type PriceInfo,
type DocumentStatusInfo,
type LetterData,
type Letter,
type Document,
type ListResponse,
type Attachment,
type Sending,
type AccountInfo,
type PersonalData,
type Coworker,
type JournalEntry,
type Invoice,
type InvoiceDetail,
type BatchStatusResponse,
type ApiErrorResponse,
// Config
type BinectClientConfig,
} from './types.js';
// Errors
export { BinectApiError, BinectAuthError } from './errors.js';
// Convenience helpers
export {
// Status predicates
isShippable,
isErroneous,
isInPreparation,
isInProductionQueue,
isPrinting,
isSent,
isCanceled,
isTerminal,
isCancelable,
// Validation helpers
getErrors,
getWarnings,
getInfoMessages,
hasErrors,
hasWarnings,
// Status description
getStatusDescription,
// Base64 utilities
fileToBase64,
bufferToBase64,
// Polling utilities
pollUntil,
waitForShippable,
type PollOptions,
} from './helpers.js';

446
src/types.ts Normal file
View File

@@ -0,0 +1,446 @@
/**
* Binect API Type Definitions
* Based on API specification v0.9.9
*/
// ============================================================================
// Enums
// ============================================================================
/**
* Document status codes as defined by the Binect API
*/
export enum DocumentStatus {
/** Document is being prepared/validated */
IN_PREPARATION = 1,
/** Document is ready to be shipped */
SHIPPABLE = 2,
/** Document is in production queue */
PRODUCTION_QUEUE = 3,
/** Document is being printed */
PRINTING = 4,
/** Document has been sent */
SENT = 5,
/** Document was canceled */
CANCELED = 6,
/** Document has errors */
ERRONEOUS = 7,
}
/**
* Envelope type options
*/
export enum EnvelopeType {
DINLANG = 'DINLANG',
C4 = 'C4',
}
/**
* Franking type options
*/
export enum FrankingType {
UNSPECIFIED = 'UNSPECIFIED',
STANDARD_FRANKING = 'STANDARD_FRANKING',
DV_FRANKING = 'DV_FRANKING',
}
/**
* Production country options
*/
export enum ProductionCountry {
UNSPECIFIED = 'UNSPECIFIED',
DE = 'DE',
AT = 'AT',
}
/**
* Response format options for document upload
*/
export enum ResponseFormat {
/** Complete validation results (default) */
FULL = 'FULL',
/** Minimal response; validation runs asynchronously */
SHORT = 'SHORT',
}
// ============================================================================
// Request Types
// ============================================================================
/**
* Options for uploading a document
*/
export interface DocumentUploadOptions {
/** Base64-encoded PDF content */
content: string;
/** Filename for the document (optional) */
filename?: string;
/** Whether to print in color (default: false) */
color?: boolean;
/** Whether to print simplex/single-sided (default: false, meaning duplex) */
simplex?: boolean;
/** Envelope type */
envelope?: EnvelopeType;
/** Franking type */
franking?: FrankingType;
/** Production country */
productionCountry?: ProductionCountry;
/** Response format */
responseFormat?: ResponseFormat;
/** Number of pages per letter for serial letter splitting */
pagesPerLetter?: number;
/** Token for serial letter splitting */
splitToken?: string;
/** Custom attributes as key-value pairs */
attributes?: DocumentAttribute[];
}
/**
* Internal API request body for document upload
* @internal
*/
export interface DocumentUploadRequestBody {
content: {
filename?: string;
content: string;
};
options?: {
simplex?: boolean;
color?: boolean;
envelope?: EnvelopeType;
franking?: FrankingType;
productionCountry?: ProductionCountry;
};
attributes?: DocumentAttribute[];
splitParams?: {
splitToken?: string;
splitAfterNumberOfPages?: number;
};
responseFormat?: ResponseFormat;
}
/**
* Options for uploading and immediately sending a document
*/
export interface DocumentUploadAndSendOptions extends DocumentUploadOptions {
/** Send immediately after upload */
send?: boolean;
}
/**
* Transformation parameters for document scaling/offset
*/
export interface DocumentTransformation {
/** Horizontal offset in mm */
offsetX?: number;
/** Vertical offset in mm */
offsetY?: number;
/** Scale factor (1.0 = 100%) */
scale?: number;
}
/**
* Cover page parameters
*/
export interface CoverPageOptions {
/** Base64-encoded PDF content for cover page */
content: string;
}
/**
* Key-value attribute
*/
export interface DocumentAttribute {
key: string;
value: string;
}
/**
* Pagination options for list endpoints
*/
export interface PaginationOptions {
/** Maximum number of results to return */
limit?: number;
/** Number of results to skip */
offset?: number;
/** Index signature for compatibility with query parameters */
[key: string]: number | undefined;
}
/**
* Options for announcing documents for sending
*/
export interface SendingAnnounceOptions {
/** Document IDs to announce */
documentIds: string[];
}
/**
* Options for canceling sendings
*/
export interface SendingCancelOptions {
/** Document IDs to cancel */
documentIds: string[];
}
/**
* Personal data update options
*/
export interface PersonalDataUpdate {
company?: string;
firstName?: string;
lastName?: string;
street?: string;
houseNumber?: string;
zipCode?: string;
city?: string;
country?: string;
phone?: string;
email?: string;
}
/**
* Account print options
*/
export interface AccountPrintOptions {
color?: boolean;
duplex?: boolean;
envelope?: EnvelopeType;
franking?: FrankingType;
productionCountry?: ProductionCountry;
}
/**
* Attachment upload options
*/
export interface AttachmentUploadOptions {
/** Base64-encoded PDF content */
content: string;
/** Name for the attachment */
name?: string;
}
// ============================================================================
// Response Types
// ============================================================================
/**
* Validation message from document processing
*/
export interface ValidationMessage {
type: 'INFO' | 'WARNING' | 'ERROR';
code: string;
message: string;
page?: number;
}
/**
* Address extracted from document
*/
export interface ExtractedAddress {
name?: string;
company?: string;
street?: string;
houseNumber?: string;
zipCode?: string;
city?: string;
country?: string;
}
/**
* Price information from API
*/
export interface PriceInfo {
priceBeforeTax: number;
priceAfterTax: number;
unit: 'EUROCENT' | string;
taxInPercent: number;
}
/**
* Document status from API
*/
export interface DocumentStatusInfo {
code: DocumentStatus;
text: string;
}
/**
* Letter data from API response
*/
export interface LetterData {
recipientAddress?: string;
price?: PriceInfo;
international?: boolean;
options?: {
simplex?: boolean;
color?: boolean;
franking?: FrankingType;
productionCountry?: ProductionCountry;
envelope?: EnvelopeType;
};
attributes?: DocumentAttribute[];
attachments?: string[];
}
/**
* Letter from API response
*/
export interface Letter {
letterType?: string;
letterData?: LetterData;
errors?: ValidationMessage[];
}
/**
* Document response from API
*/
export interface Document {
/** Document ID (numeric) */
id: number;
/** Filename of uploaded document */
filename?: string;
/** Number of pages in document */
numberOfPages: number;
/** Document status */
status: DocumentStatusInfo;
/** Type of document */
documentType?: 'LETTER' | 'SERIALLETTER' | string;
/** Letter details (for single letters) */
letter?: Letter;
/** Array of letters (for serial letters) */
letters?: Letter[];
}
/**
* List response wrapper
*/
export interface ListResponse<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
/**
* Attachment response from API
*/
export interface Attachment {
attachmentId: string;
name: string;
pageCount: number;
createdAt: string;
}
/**
* Sending/shipment response from API
*/
export interface Sending {
documentId: string;
status: DocumentStatus;
price?: number;
trackingId?: string;
shippedAt?: string;
deliveredAt?: string;
}
/**
* Account balance/credit information
*/
export interface AccountInfo {
credit: number;
currency: string;
debitornumber: string;
}
/**
* Personal data response
*/
export interface PersonalData {
company?: string;
firstName?: string;
lastName?: string;
street?: string;
houseNumber?: string;
zipCode?: string;
city?: string;
country?: string;
phone?: string;
email?: string;
debitornumber: string;
}
/**
* Coworker information
*/
export interface Coworker {
debitornumber: string;
firstName?: string;
lastName?: string;
email?: string;
}
/**
* Journal/transaction entry
*/
export interface JournalEntry {
date: string;
description: string;
amount: number;
balance: number;
documentId?: string;
}
/**
* Invoice summary
*/
export interface Invoice {
invoiceNumber: string;
date: string;
amount: number;
currency: string;
}
/**
* Invoice detail with transactions
*/
export interface InvoiceDetail extends Invoice {
entries: JournalEntry[];
}
/**
* Batch status check response
*/
export interface BatchStatusResponse {
statuses: Record<string, DocumentStatus>;
}
// ============================================================================
// Error Types
// ============================================================================
/**
* API error response structure
*/
export interface ApiErrorResponse {
error?: string;
message?: string;
details?: string[];
validationErrors?: ValidationMessage[];
}
// ============================================================================
// Client Configuration
// ============================================================================
/**
* Configuration options for BinectClient
*/
export interface BinectClientConfig {
/** Binect username (email) */
username: string;
/** Binect password */
password: string;
/** Base URL override (default: https://app.binect.de/binectapi/v1) */
baseUrl?: string;
}