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

22
.eslintrc.json Normal file
View File

@@ -0,0 +1,22 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "error",
"no-console": "warn"
},
"ignorePatterns": ["dist", "node_modules", "*.js", "vitest.config.ts"]
}

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ coverage/
tmp/
temp/
*.tmp
dummy-credentials.ini

View File

@@ -2,38 +2,57 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
```bash
npm install # Install dependencies
npm run build # Build TypeScript to dist/
npm test # Run tests with vitest
npm run typecheck # Type check without emitting
```
## Project Overview
Binect-JS is a JavaScript/TypeScript wrapper for the Binect REST API (https://app.binect.de/index.jsp?id=api) that enables sending PDF documents as physical mail. The project consists of two artifacts:
1. **Binect-JS SDK** (`@binect/js`) - A thin API wrapper
2. **Binect Explorer** (`@binect/explorer`) - A browser-based interactive tool for learning and experimentation
1. **Binect-JS SDK** (`@binect/js`) - A thin API wrapper in `src/`
2. **Binect Explorer** - A browser-based interactive tool in `explorer/`
## Architecture Principles
## Architecture
The SDK is organized around domain-aligned sub-clients that mirror the API vocabulary:
- `documents` - PDF upload, status inspection, parameter modification
- `attachments` - Attachment handling
- `sendings` - Mail dispatch triggers, cancellation
- `accounts` - Account management
- `invoices` - Invoice access
- `client.documents` - PDF upload, status inspection, parameter modification
- `client.attachments` - Attachment handling
- `client.sendings` - Mail dispatch triggers, cancellation
- `client.accounts` - Account management
- `client.invoices` - Invoice access
### SDK Layer Separation
**Core API Layer** (authoritative): 1:1 semantic mapping to REST endpoints. Methods like `documents.uploadPdf`, `documents.getStatus`, `sendings.announce`, `sendings.cancel`.
**Core API Layer** (authoritative): 1:1 semantic mapping to REST endpoints. Methods like `documents.upload`, `documents.get`, `sendings.send`, `sendings.cancel`.
**Convenience Layer** (optional, non-authoritative): Additive helpers like status predicates (`isShippable`), error extraction, polling helpers. These must never be the only way to perform an action.
**Convenience Layer** (optional, non-authoritative): Additive helpers in `src/helpers.ts` like status predicates (`isShippable`), error extraction, polling helpers. These must never be the only way to perform an action.
## Design Constraints
- **No backend dependency**: Must function entirely in browser/JS runtime
- **No runtime dependencies**: Uses native `fetch` API only
- **No semantic reinterpretation**: Wrapper must not alter business meaning or outcomes
- **Transparency over abstraction**: Developers must reason about actual API calls
- **No default retries**: Network behavior must be explicit and opt-in
- **Authentication**: HTTP Basic Auth, credentials are ephemeral (not stored/cached)
## Key Files
- `src/client.ts` - Main BinectClient class
- `src/clients/*.ts` - Domain sub-clients
- `src/types.ts` - TypeScript type definitions
- `src/errors.ts` - BinectApiError and BinectAuthError
- `src/http.ts` - Low-level HTTP client
- `src/helpers.ts` - Convenience helpers (predicates, polling)
- `explorer/index.html` - Standalone Explorer UI
## API Integration Notes
- Uploads use base64-encoded PDFs
- All non-success responses surface as structured errors preserving HTTP status, endpoint, and parsed response
- The wrapper does not reinterpret business errors from the API
- Uploads use base64-encoded PDFs (max 12 MB)
- All non-success responses surface as structured `BinectApiError`
- Document status codes: 1=Preparing, 2=Shippable, 3=Queue, 4=Printing, 5=Sent, 6=Canceled, 7=Error

207
README.md
View File

@@ -1,11 +1,206 @@
BinectJs
# Binect-JS
*Javascript Binect API wrapper*
A JavaScript/TypeScript wrapper for the [Binect API](https://app.binect.de/index.jsp?id=api) to send PDF documents as physical mail via Deutsche Post.
# Binect JS - Send Papermail from your Browser
## Features
An easy to use javascript library to send pdf-documents as papermail using the app.binect.de API.
- **SDK (`@binect/js`)**: Type-safe API wrapper with domain-aligned sub-clients
- **Explorer**: Browser-based interactive tool for learning and testing the API
- **Zero Dependencies**: Uses native `fetch` API, works in browsers and Node.js >= 18
For documentation of the API see: https://app.binect.de/index.jsp?id=api
## Installation
xxx
```bash
npm install @binect/js
```
## Quick Start
```typescript
import { BinectClient, DocumentStatus, isShippable } from '@binect/js';
import { readFileSync } from 'fs';
// Create client with your Binect credentials
const client = new BinectClient({
username: 'your@email.com',
password: 'your-password',
});
// Upload a PDF document
const pdfContent = readFileSync('letter.pdf').toString('base64');
const document = await client.documents.upload({
content: pdfContent,
color: false,
duplex: true,
envelope: 'DINLANG',
});
console.log('Document ID:', document.documentId);
console.log('Status:', document.status);
// Check if ready to send
if (isShippable(document)) {
// Send the document as physical mail
const sending = await client.sendings.send(document.documentId);
console.log('Mail dispatched!');
}
```
## SDK API Reference
The SDK provides domain-aligned sub-clients:
### Documents (`client.documents`)
- `upload(options)` - Upload a PDF document
- `list(pagination?)` - List shippable documents
- `listErrors(pagination?)` - List documents with errors
- `get(documentId)` - Get document details
- `delete(documentId)` - Delete a document
- `getPdf(documentId)` - Get PDF preview
- `getPng(documentId)` - Get PNG preview
- `getAttributes(documentId)` - Get document attributes
- `setAttributes(documentId, attributes)` - Set attributes
- `applyTransformation(documentId, transformation)` - Apply scaling/offset
- `addCoverPage(documentId, options)` - Add cover page
### Sendings (`client.sendings`)
- `announce(documentIds)` - Announce documents for delivery
- `list(pagination?)` - List all sendings
- `send(documentId)` - Trigger single document send
- `cancel(documentId)` - Cancel a sending
- `getStatus(documentIds)` - Batch status check
### Attachments (`client.attachments`)
- `upload(options)` - Upload an attachment
- `list(pagination?)` - List all attachments
- `get(attachmentId)` - Get attachment details
- `delete(attachmentId)` - Delete an attachment
- `attachToDocuments(attachmentId, documentIds)` - Attach to documents
### Accounts (`client.accounts`)
- `get()` - Get account balance/credit info
- `getPersonalData()` - Get personal data
- `updatePersonalData(data)` - Update personal data
- `getOptions()` - Get default print options
- `updateOptions(options)` - Update print options
- `getJournal(month)` - Get transaction journal
### Invoices (`client.invoices`)
- `list(pagination?)` - List all invoices
- `get(invoiceNumber)` - Get invoice details
- `getPdf(invoiceNumber)` - Download invoice PDF
## Convenience Helpers
```typescript
import {
isShippable,
isErroneous,
isSent,
isTerminal,
isCancelable,
hasErrors,
hasWarnings,
getErrors,
getStatusDescription,
waitForShippable,
fileToBase64,
} from '@binect/js';
// Status predicates
if (isShippable(document)) { /* ... */ }
if (isErroneous(document)) { /* ... */ }
// Validation helpers
const errors = getErrors(document);
if (hasWarnings(document)) {
console.log('Document has warnings');
}
// Polling (opt-in)
const readyDoc = await waitForShippable(
() => client.documents.get(docId),
{ intervalMs: 2000, maxAttempts: 30 }
);
// Base64 encoding (browser)
const file = document.querySelector('input[type="file"]').files[0];
const base64 = await fileToBase64(file);
```
## Explorer
The Binect Explorer is a browser-based interactive tool for:
- Learning the Binect API
- Testing document uploads and mail dispatch
- Managing use case profiles
Open `explorer/index.html` in a browser to use it.
## Document Status Codes
| Code | Status | Description |
|------|--------|-------------|
| 1 | IN_PREPARATION | Document is being validated |
| 2 | SHIPPABLE | Ready to send |
| 3 | PRODUCTION_QUEUE | In production queue |
| 4 | PRINTING | Currently printing |
| 5 | SENT | Mail has been sent |
| 6 | CANCELED | Sending was canceled |
| 7 | ERRONEOUS | Document has validation errors |
## Development
```bash
# Install dependencies
npm install
# Build
npm run build
# Run tests
npm test
# Run e2e tests (requires Binect credentials)
BINECT_USERNAME=your@email.com BINECT_PASSWORD=yourpass npm run test:e2e
# Type check
npm run typecheck
```
## Project Structure
```
binect-js/
├── src/ # SDK source code
│ ├── client.ts # Main BinectClient
│ ├── clients/ # Domain sub-clients
│ ├── types.ts # Type definitions
│ ├── errors.ts # Error classes
│ ├── http.ts # HTTP layer
│ └── helpers.ts # Convenience helpers
├── explorer/ # Browser-based Explorer UI
├── tests/ # Test suite
├── architecture/ # Architecture Decision Records
└── research/ # API documentation research
```
## Architecture
See [architecture/](./architecture/) for Architecture Decision Records (ADRs):
- ADR-001: SDK Architecture
- ADR-002: No External Dependencies
- ADR-003: Explorer Architecture
## API Documentation
Official Binect API documentation: https://app.binect.de/index.jsp?id=api
## License
MIT

View File

@@ -0,0 +1,78 @@
# ADR-001: SDK Architecture
## Status
Accepted
## Context
The Binect-JS SDK needs to provide a JavaScript/TypeScript wrapper for the Binect REST API. Per the PRD and TSD, the SDK must:
- Be transparent and thin (no semantic reinterpretation)
- Work in both browser and Node.js environments
- Use domain-aligned sub-clients mirroring the API vocabulary
- Separate core API layer from optional convenience helpers
- Handle HTTP Basic Authentication without storing credentials
## Decision
### 1. Client Structure
We adopt a **main client with domain-aligned sub-clients** pattern:
```typescript
const client = new BinectClient({ username, password });
client.documents.upload(...)
client.sendings.announce(...)
client.attachments.list(...)
client.accounts.get(...)
client.invoices.list(...)
```
Each sub-client maps to an API domain and provides 1:1 method mapping to REST endpoints.
### 2. HTTP Layer
We use the native `fetch` API for HTTP requests:
- Works in both browser and Node.js (>=18)
- No external dependencies
- Predictable behavior without hidden retries or timeouts
### 3. Authentication
- Credentials passed at client construction
- Converted to Base64 Basic Auth header per request
- Never stored beyond client instance lifetime
- No automatic credential refresh
### 4. Error Handling
- Non-2xx responses throw `BinectApiError` with:
- HTTP status code
- Endpoint path
- Parsed response body (when available)
- Network errors surface as-is (no wrapping)
### 5. Type Safety
- Full TypeScript types for all API requests/responses
- Enums for document status, envelope types, franking types
- Generic response types preserving API structure
### 6. Convenience Layer (Optional)
Additive helpers in separate modules:
- Status predicates (`isShippable`, `isErroneous`)
- Polling utilities (opt-in, no default behavior)
- Response extractors
These never replace core methods.
## Consequences
### Positive
- Clear mental model mapping to API documentation
- No hidden behavior or magic
- Works in all JavaScript environments with fetch
- Type-safe development experience
### Negative
- Developers must understand API structure
- No automatic retry on transient failures (by design)
- More verbose than heavily abstracted SDKs
## References
- PRD: Section 3.1 (Product Intent)
- TSD: Section 3 (SDK Technical Orientation)
- Binect API: https://app.binect.de/index.jsp?id=api

View File

@@ -0,0 +1,49 @@
# ADR-002: No External Runtime Dependencies
## Status
Accepted
## Context
The SDK needs to make HTTP requests and handle authentication. Common approaches include using libraries like axios, node-fetch, or got for HTTP, and various utilities for base64 encoding.
Per the TSD (Section 2, Design Guardrail #1): "No backend dependency - The product must function entirely in browser and JavaScript runtime environments."
## Decision
The SDK will have **zero runtime dependencies**:
1. **HTTP Requests**: Use native `fetch` API
- Available in all modern browsers
- Built into Node.js >= 18
- No polyfills required for target environments
2. **Base64 Encoding**: Use native APIs
- Browser: `btoa()` / `atob()`
- Node.js: `Buffer.from().toString('base64')`
- Provide isomorphic wrapper
3. **Type Checking**: TypeScript (dev dependency only)
## Consequences
### Positive
- No dependency vulnerabilities to manage
- Smaller bundle size
- Predictable behavior (no library-specific quirks)
- Works identically in browser and Node.js
- No version conflicts with consumer projects
### Negative
- Must implement utility functions ourselves
- Cannot leverage library conveniences (interceptors, etc.)
- Requires Node.js >= 18 (has native fetch)
## Alternatives Considered
1. **axios**: Popular but adds ~13KB and has had security vulnerabilities
2. **node-fetch**: Would require different code paths for browser/Node
3. **ky**: Modern but still an external dependency
## References
- TSD: Section 2 (Design Guardrails)
- Node.js fetch: https://nodejs.org/docs/latest-v18.x/api/globals.html#fetch

View File

@@ -0,0 +1,72 @@
# ADR-003: Explorer Architecture
## Status
Accepted
## Context
The Binect Explorer needs to be a browser-based interactive tool for:
- Learning the Binect API
- Experimentation and evaluation
- Safe testing before production integration
Per the TSD (Section 4), the Explorer:
- Must operate without server-side components
- Must clearly distinguish between preview and send operations
- Must require explicit confirmation for destructive actions
- Is a learning tool, not an operations dashboard
## Decision
### 1. Technology Stack
We use a **vanilla JavaScript/HTML/CSS** approach:
- No framework dependencies (React, Vue, etc.)
- Single HTML file with embedded CSS and JS
- Can use the SDK directly via module import
- Easy to host as a static file
Rationale: Per TSD Section 7, the product must remain independent of specific UI frameworks. A vanilla approach ensures maximum portability and simplicity.
### 2. Architecture Pattern
**Component-based with vanilla JS**:
- Modular JavaScript functions for each feature
- Event-driven UI updates
- State management via simple objects
### 3. Feature Organization
The Explorer UI is organized around the API domains:
- **Credentials Panel**: Input and manage API credentials
- **Documents Panel**: Upload, view, manage documents
- **Sendings Panel**: Announce and track mail dispatch
- **Attachments Panel**: Manage attachments
- **Account Panel**: View account info and options
### 4. Safety Features
Per TSD requirements:
- Credentials are ephemeral by default (cleared on page refresh)
- Optional local storage for convenience (opt-in)
- Send operations require explicit confirmation dialog
- Preview available before sending
- Clear visual distinction between safe (read) and destructive (send/delete) actions
### 5. Use Case Profiles
- Stored in browser localStorage
- Export/import as JSON files
- Contain only parameter configurations, not workflows
## Consequences
### Positive
- Zero external dependencies
- Works as single HTML file
- Easy to understand and modify
- Can be hosted anywhere (CDN, local file, etc.)
- Aligns with TSD requirement for framework independence
### Negative
- Less sophisticated UI compared to framework-based apps
- Manual DOM manipulation
- No virtual DOM or reactive updates
## References
- TSD: Section 4 (Explorer Technical Orientation)
- PRD: Section 4.1 (Functional Expectations)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1335
explorer/index.html Normal file

File diff suppressed because it is too large Load Diff

1849
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@binect/js",
"version": "0.1.0",
"description": "JavaScript/TypeScript wrapper for the Binect API - Send PDF documents as physical mail",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run",
"test:watch": "node --experimental-vm-modules node_modules/vitest/vitest.mjs",
"test:e2e": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run tests/e2e.test.ts",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"prepublishOnly": "npm run build"
},
"keywords": [
"binect",
"mail",
"letter",
"post",
"pdf",
"api",
"deutsche-post"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,115 @@
# Binect API REST Specification
**Source:** https://app.binect.de/binectapi/v1_swagger_api_kernel.json
**Retrieved:** 2026-01-14
## Overview
The Binect API (v0.9.9) is a letter shipping service that handles document upload, validation, processing, and Deutsche Post delivery. The API uses HTTP Basic Authentication and HTTPS exclusively.
## Base Configuration
- **Base Path:** `/binectapi/v1`
- **Host:** `app.binect.de`
- **Scheme:** HTTPS only
- **Authentication:** HTTP Basic Auth (required for all endpoints)
- **Contact:** kontakt@binect.de
## Core Endpoints
### Documents Management
**Upload & Retrieve:**
- `POST /documents` Upload letters/serial letters with base64-encoded content
- `GET /documents` List shippable documents (status 2)
- `GET /documents/errors` List erroneous documents (status 7)
- `GET /documents/{documentID}` Fetch specific document
- `DELETE /documents/{documentID}` Remove document
**Document Attributes:**
- `GET /documents/{documentID}/attributes` Get all attributes
- `POST /documents/{documentID}/attributes` Set attributes
- `GET /documents/{documentID}/attributes/{key}` Get specific attribute
- `PUT /documents/{documentID}/attributes/{key}` Update specific attribute
- `DELETE /documents/{documentID}/attributes/{key}` Delete specific attribute
**Document Modifications:**
- `PUT /documents/{documentID}/transformations` Apply scaling/offset adjustments
- `DELETE /documents/{documentID}/transformations` Revert to original
- `PUT /documents/{documentID}/coverpage` Add cover page
- `DELETE /documents/{documentID}/coverpage` Remove cover page
- `GET /documents/{documentID}/pdf` PDF preview
- `GET /documents/{documentID}/png` PNG preview
**Attachments on Documents:**
- `GET /documents/{documentID}/attachments` Get document attachments
- `POST /documents/{documentID}/attachments` Add attachment to document
- `PATCH /documents/{documentID}/attachments` Update attachments
- `DELETE /documents/{documentID}/attachments` Remove attachments
### Sendings (Shipments)
- `POST /sendings` Announce documents for delivery
- `GET /sendings` List all shipments (statuses 3-7)
- `PUT /sendings` Cancel unshipped letters
- `POST /sendings/{documentID}` Trigger single shipment
- `GET /sendings/{documentID}` Get specific sending
- `PUT /sendings/{documentID}` Cancel specific sending
- `DELETE /sendings/{documentID}` Delete sending
- `POST /sendings/document` Upload and immediately send
- `GET /sendings/status` Batch status check
### Attachments (Standalone)
- `POST /attachments` Upload attachment
- `GET /attachments` List all attachments
- `GET /attachments/{attachmentID}` Get specific attachment
- `DELETE /attachments/{attachmentID}` Delete attachment
- `GET /attachments/{attachmentID}/pdf` Preview attachment PDF
- `GET /attachments/{attachmentID}/documents` Find associated documents
- `PATCH /attachments/{attachmentID}/documents` Attach to multiple documents
### Account Management
- `GET /accounts` Retrieve credit/financial data
- `GET /accounts/personaldata` Get user details
- `PATCH /accounts/personaldata` Update user details
- `GET /accounts/options` Get default print options
- `PUT /accounts/options` Update default print options
- `GET /accounts/coworkers` List team members
- `GET /accounts/coworkers/{debitornumber}/journal/{month}` Coworker transactions
- `GET /accounts/journal/{month}` Account transactions
### Invoicing
- `GET /invoices` List all invoices
- `GET /invoices/{invoiceNumber}` Fetch invoice transactions
- `GET /invoices/{invoiceNumber}/pdf` Download invoice PDF
## Key Data Models
### Document Status Codes
- 1: In preparation
- 2: Shippable
- 3: Production queue
- 4: Printing
- 5: Sent
- 6: Canceled
- 7: Erroneous
### Envelope Options
- DINLANG
- C4
### Franking Types
- UNSPECIFIED
- STANDARD_FRANKING
- DV_FRANKING
### Production Countries
- UNSPECIFIED
- DE
- AT
### Response Format Options
- FULL (default): Complete validation results
- SHORT: Minimal response; validation runs asynchronously
## Constraints
- Maximum file size: 12 MB
- Pagination supported via `limit` and `offset` parameters
- Serial letters support token-based or page-count splitting
- Transformations and cover pages can only be applied to original documents

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;
}

59
tests/client.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { BinectClient } from '../src/client.js';
import { DocumentsClient } from '../src/clients/documents.js';
import { AttachmentsClient } from '../src/clients/attachments.js';
import { SendingsClient } from '../src/clients/sendings.js';
import { AccountsClient } from '../src/clients/accounts.js';
import { InvoicesClient } from '../src/clients/invoices.js';
describe('BinectClient', () => {
it('creates client with required config', () => {
const client = new BinectClient({
username: 'test@example.com',
password: 'password123',
});
expect(client).toBeInstanceOf(BinectClient);
});
it('creates client with custom baseUrl', () => {
const client = new BinectClient({
username: 'test@example.com',
password: 'password123',
baseUrl: 'https://custom.api.com/v1',
});
expect(client).toBeInstanceOf(BinectClient);
});
describe('sub-clients', () => {
let client: BinectClient;
beforeAll(() => {
client = new BinectClient({
username: 'test@example.com',
password: 'password123',
});
});
it('has documents client', () => {
expect(client.documents).toBeInstanceOf(DocumentsClient);
});
it('has attachments client', () => {
expect(client.attachments).toBeInstanceOf(AttachmentsClient);
});
it('has sendings client', () => {
expect(client.sendings).toBeInstanceOf(SendingsClient);
});
it('has accounts client', () => {
expect(client.accounts).toBeInstanceOf(AccountsClient);
});
it('has invoices client', () => {
expect(client.invoices).toBeInstanceOf(InvoicesClient);
});
});
});

89
tests/errors.test.ts Normal file
View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { BinectApiError, BinectAuthError } from '../src/errors.js';
describe('BinectApiError', () => {
it('creates error with all properties', () => {
const response = { error: 'Not found', message: 'Document not found' };
const error = new BinectApiError('Not found', 404, '/documents/123', 'GET', response);
expect(error.name).toBe('BinectApiError');
expect(error.message).toBe('Not found');
expect(error.status).toBe(404);
expect(error.endpoint).toBe('/documents/123');
expect(error.method).toBe('GET');
expect(error.response).toEqual(response);
});
it('creates error without response', () => {
const error = new BinectApiError('Server error', 500, '/documents', 'POST');
expect(error.status).toBe(500);
expect(error.response).toBeNull();
});
it('has correct inheritance', () => {
const error = new BinectApiError('Test', 400, '/test', 'GET');
expect(error instanceof Error).toBe(true);
expect(error instanceof BinectApiError).toBe(true);
});
describe('toDetailedString', () => {
it('includes all error details', () => {
const response = {
error: 'Validation error',
message: 'Invalid document format',
details: ['Page size incorrect', 'Missing address'],
};
const error = new BinectApiError('Validation failed', 400, '/documents', 'POST', response);
const detailed = error.toDetailedString();
expect(detailed).toContain('BinectApiError: Validation failed');
expect(detailed).toContain('Status: 400');
expect(detailed).toContain('Endpoint: POST /documents');
expect(detailed).toContain('Error: Validation error');
expect(detailed).toContain('Message: Invalid document format');
expect(detailed).toContain('Details: Page size incorrect, Missing address');
});
it('handles minimal error response', () => {
const error = new BinectApiError('Server error', 500, '/test', 'GET');
const detailed = error.toDetailedString();
expect(detailed).toContain('BinectApiError: Server error');
expect(detailed).toContain('Status: 500');
// Should not contain "Error:" as a separate field (only in class name)
expect(detailed).not.toContain(' Error:');
expect(detailed).not.toContain(' Message:');
});
});
});
describe('BinectAuthError', () => {
it('creates authentication error', () => {
const error = new BinectAuthError('/accounts', 'GET');
expect(error.name).toBe('BinectAuthError');
expect(error.message).toBe('Authentication failed: Invalid credentials');
expect(error.status).toBe(401);
expect(error.endpoint).toBe('/accounts');
expect(error.method).toBe('GET');
});
it('includes response when provided', () => {
const response = { error: 'Unauthorized', message: 'Invalid credentials' };
const error = new BinectAuthError('/accounts', 'GET', response);
expect(error.response).toEqual(response);
});
it('inherits from BinectApiError', () => {
const error = new BinectAuthError('/test', 'GET');
expect(error instanceof Error).toBe(true);
expect(error instanceof BinectApiError).toBe(true);
expect(error instanceof BinectAuthError).toBe(true);
});
});

281
tests/helpers.test.ts Normal file
View File

@@ -0,0 +1,281 @@
import { describe, it, expect } from 'vitest';
import {
isShippable,
isErroneous,
isInPreparation,
isInProductionQueue,
isPrinting,
isSent,
isCanceled,
isTerminal,
isCancelable,
getErrors,
getWarnings,
getInfoMessages,
hasErrors,
hasWarnings,
getStatusDescription,
pollUntil,
} from '../src/helpers.js';
import { DocumentStatus } from '../src/types.js';
import type { Document, ValidationMessage } from '../src/types.js';
// Helper to create mock documents with specific status
function createMockDocument(status: DocumentStatus, validationMessages?: ValidationMessage[]): Document {
return {
id: 12345,
filename: 'test-doc.pdf',
numberOfPages: 1,
status: {
code: status,
text: 'Test status',
},
documentType: 'LETTER',
letter: {
letterType: 'LETTERDATA',
letterData: {
options: {
simplex: false,
color: false,
franking: 'STANDARD_FRANKING',
productionCountry: 'DE',
envelope: 'DINLANG',
},
},
errors: validationMessages ?? [],
},
};
}
describe('Status Predicates', () => {
describe('isShippable', () => {
it('returns true for status SHIPPABLE', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE);
expect(isShippable(doc)).toBe(true);
});
it('returns false for other statuses', () => {
expect(isShippable(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isShippable(createMockDocument(DocumentStatus.ERRONEOUS))).toBe(false);
expect(isShippable(createMockDocument(DocumentStatus.SENT))).toBe(false);
});
});
describe('isErroneous', () => {
it('returns true for status ERRONEOUS', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS);
expect(isErroneous(doc)).toBe(true);
});
it('returns false for other statuses', () => {
expect(isErroneous(createMockDocument(DocumentStatus.SHIPPABLE))).toBe(false);
});
});
describe('isInPreparation', () => {
it('returns true for status IN_PREPARATION', () => {
const doc = createMockDocument(DocumentStatus.IN_PREPARATION);
expect(isInPreparation(doc)).toBe(true);
});
});
describe('isInProductionQueue', () => {
it('returns true for status PRODUCTION_QUEUE', () => {
const doc = createMockDocument(DocumentStatus.PRODUCTION_QUEUE);
expect(isInProductionQueue(doc)).toBe(true);
});
});
describe('isPrinting', () => {
it('returns true for status PRINTING', () => {
const doc = createMockDocument(DocumentStatus.PRINTING);
expect(isPrinting(doc)).toBe(true);
});
});
describe('isSent', () => {
it('returns true for status SENT', () => {
const doc = createMockDocument(DocumentStatus.SENT);
expect(isSent(doc)).toBe(true);
});
});
describe('isCanceled', () => {
it('returns true for status CANCELED', () => {
const doc = createMockDocument(DocumentStatus.CANCELED);
expect(isCanceled(doc)).toBe(true);
});
});
describe('isTerminal', () => {
it('returns true for SENT, CANCELED, and ERRONEOUS', () => {
expect(isTerminal(createMockDocument(DocumentStatus.SENT))).toBe(true);
expect(isTerminal(createMockDocument(DocumentStatus.CANCELED))).toBe(true);
expect(isTerminal(createMockDocument(DocumentStatus.ERRONEOUS))).toBe(true);
});
it('returns false for non-terminal statuses', () => {
expect(isTerminal(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.SHIPPABLE))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.PRODUCTION_QUEUE))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.PRINTING))).toBe(false);
});
});
describe('isCancelable', () => {
it('returns true for PRODUCTION_QUEUE and PRINTING', () => {
expect(isCancelable(createMockDocument(DocumentStatus.PRODUCTION_QUEUE))).toBe(true);
expect(isCancelable(createMockDocument(DocumentStatus.PRINTING))).toBe(true);
});
it('returns false for non-cancelable statuses', () => {
expect(isCancelable(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isCancelable(createMockDocument(DocumentStatus.SENT))).toBe(false);
expect(isCancelable(createMockDocument(DocumentStatus.CANCELED))).toBe(false);
});
});
});
describe('Validation Helpers', () => {
const mockMessages: ValidationMessage[] = [
{ type: 'ERROR', code: 'ERR001', message: 'Error 1' },
{ type: 'ERROR', code: 'ERR002', message: 'Error 2' },
{ type: 'WARNING', code: 'WARN001', message: 'Warning 1' },
{ type: 'INFO', code: 'INFO001', message: 'Info 1' },
];
describe('getErrors', () => {
it('returns only error messages', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS, mockMessages);
const errors = getErrors(doc);
expect(errors).toHaveLength(2);
expect(errors.every(m => m.type === 'ERROR')).toBe(true);
});
it('returns empty array when no validation messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE);
expect(getErrors(doc)).toEqual([]);
});
});
describe('getWarnings', () => {
it('returns only warning messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
const warnings = getWarnings(doc);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.type).toBe('WARNING');
});
});
describe('getInfoMessages', () => {
it('returns only info messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
const info = getInfoMessages(doc);
expect(info).toHaveLength(1);
expect(info[0]?.type).toBe('INFO');
});
});
describe('hasErrors', () => {
it('returns true when document has errors', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS, mockMessages);
expect(hasErrors(doc)).toBe(true);
});
it('returns false when document has no errors', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, [
{ type: 'WARNING', code: 'WARN001', message: 'Warning' },
]);
expect(hasErrors(doc)).toBe(false);
});
});
describe('hasWarnings', () => {
it('returns true when document has warnings', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
expect(hasWarnings(doc)).toBe(true);
});
it('returns false when document has no warnings', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, [
{ type: 'INFO', code: 'INFO001', message: 'Info' },
]);
expect(hasWarnings(doc)).toBe(false);
});
});
});
describe('getStatusDescription', () => {
it('returns correct descriptions for all statuses', () => {
expect(getStatusDescription(DocumentStatus.IN_PREPARATION)).toBe('In preparation');
expect(getStatusDescription(DocumentStatus.SHIPPABLE)).toBe('Ready to ship');
expect(getStatusDescription(DocumentStatus.PRODUCTION_QUEUE)).toBe('In production queue');
expect(getStatusDescription(DocumentStatus.PRINTING)).toBe('Printing');
expect(getStatusDescription(DocumentStatus.SENT)).toBe('Sent');
expect(getStatusDescription(DocumentStatus.CANCELED)).toBe('Canceled');
expect(getStatusDescription(DocumentStatus.ERRONEOUS)).toBe('Has errors');
});
it('returns "Unknown status" for invalid status', () => {
expect(getStatusDescription(999 as DocumentStatus)).toBe('Unknown status');
});
});
describe('Polling Utilities', () => {
describe('pollUntil', () => {
it('returns immediately when condition is met', async () => {
let callCount = 0;
const result = await pollUntil(
async () => {
callCount++;
return { value: 42 };
},
(r) => r.value === 42,
{ intervalMs: 10 }
);
expect(result.value).toBe(42);
expect(callCount).toBe(1);
});
it('polls until condition is met', async () => {
let callCount = 0;
const result = await pollUntil(
async () => {
callCount++;
return { value: callCount };
},
(r) => r.value >= 3,
{ intervalMs: 10 }
);
expect(result.value).toBe(3);
expect(callCount).toBe(3);
});
it('throws when max attempts exceeded', async () => {
await expect(
pollUntil(
async () => ({ value: 1 }),
(r) => r.value > 100,
{ intervalMs: 10, maxAttempts: 3 }
)
).rejects.toThrow('Polling exceeded maximum attempts (3)');
});
it('respects abort signal', async () => {
const controller = new AbortController();
// Abort immediately
controller.abort();
await expect(
pollUntil(
async () => ({ value: 1 }),
() => false,
{ intervalMs: 10, signal: controller.signal }
)
).rejects.toThrow('Polling aborted');
});
});
});

221
tests/http.test.ts Normal file
View File

@@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HttpClient, encodeBasicAuth, DEFAULT_BASE_URL } from '../src/http.js';
import { BinectApiError, BinectAuthError } from '../src/errors.js';
describe('encodeBasicAuth', () => {
it('encodes credentials correctly', () => {
const encoded = encodeBasicAuth('user@example.com', 'password123');
// "user@example.com:password123" in base64
expect(encoded).toBe('dXNlckBleGFtcGxlLmNvbTpwYXNzd29yZDEyMw==');
});
it('handles special characters', () => {
const encoded = encodeBasicAuth('user@example.com', 'p@ss:word!');
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
expect(decoded).toBe('user@example.com:p@ss:word!');
});
});
describe('DEFAULT_BASE_URL', () => {
it('points to Binect API', () => {
expect(DEFAULT_BASE_URL).toBe('https://app.binect.de/binectapi/v1');
});
});
describe('HttpClient', () => {
let client: HttpClient;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
client = new HttpClient({
baseUrl: 'https://api.example.com',
username: 'testuser',
password: 'testpass',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('request', () => {
it('makes GET request with auth header', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => JSON.stringify({ id: '123' }),
});
const result = await client.request<{ id: string }>({
method: 'GET',
path: '/test',
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
Accept: 'application/json',
}),
})
);
expect(result.id).toBe('123');
});
it('makes POST request with JSON body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => JSON.stringify({ success: true }),
});
await client.request({
method: 'POST',
path: '/test',
body: { data: 'test' },
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ data: 'test' }),
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
it('adds query parameters to URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => '{}',
});
await client.request({
method: 'GET',
path: '/test',
query: { limit: 10, offset: 20 },
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test?limit=10&offset=20',
expect.any(Object)
);
});
it('skips undefined query parameters', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => '{}',
});
await client.request({
method: 'GET',
path: '/test',
query: { limit: 10, offset: undefined },
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test?limit=10',
expect.any(Object)
);
});
it('handles empty response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'content-type': 'text/plain' }),
text: async () => '',
});
const result = await client.request({ method: 'DELETE', path: '/test' });
expect(result).toBeUndefined();
});
it('throws BinectAuthError on 401', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: async () => JSON.stringify({ error: 'Unauthorized' }),
});
await expect(
client.request({ method: 'GET', path: '/test' })
).rejects.toThrow(BinectAuthError);
});
it('throws BinectApiError on other errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => JSON.stringify({ message: 'Not found' }),
});
await expect(
client.request({ method: 'GET', path: '/test' })
).rejects.toThrow(BinectApiError);
try {
await client.request({ method: 'GET', path: '/test' });
} catch (e) {
if (e instanceof BinectApiError) {
expect(e.status).toBe(404);
expect(e.message).toBe('Not found');
}
}
});
it('handles non-JSON error response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
// Now captures the raw text response as the error message
await expect(
client.request({ method: 'GET', path: '/test' })
).rejects.toThrow('Internal Server Error');
});
});
describe('requestRaw', () => {
it('returns raw response for binary data', async () => {
const mockResponse = {
ok: true,
headers: new Headers({ 'content-type': 'application/pdf' }),
blob: async () => new Blob(['pdf content']),
};
mockFetch.mockResolvedValueOnce(mockResponse);
const response = await client.requestRaw({
method: 'GET',
path: '/documents/123/pdf',
});
expect(response).toBe(mockResponse);
});
it('throws on error response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => JSON.stringify({ message: 'Document not found' }),
});
await expect(
client.requestRaw({ method: 'GET', path: '/documents/123/pdf' })
).rejects.toThrow(BinectApiError);
});
});
});

52
tests/types.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import {
DocumentStatus,
EnvelopeType,
FrankingType,
ProductionCountry,
ResponseFormat,
} from '../src/types.js';
describe('Enums', () => {
describe('DocumentStatus', () => {
it('has correct values', () => {
expect(DocumentStatus.IN_PREPARATION).toBe(1);
expect(DocumentStatus.SHIPPABLE).toBe(2);
expect(DocumentStatus.PRODUCTION_QUEUE).toBe(3);
expect(DocumentStatus.PRINTING).toBe(4);
expect(DocumentStatus.SENT).toBe(5);
expect(DocumentStatus.CANCELED).toBe(6);
expect(DocumentStatus.ERRONEOUS).toBe(7);
});
});
describe('EnvelopeType', () => {
it('has correct values', () => {
expect(EnvelopeType.DINLANG).toBe('DINLANG');
expect(EnvelopeType.C4).toBe('C4');
});
});
describe('FrankingType', () => {
it('has correct values', () => {
expect(FrankingType.UNSPECIFIED).toBe('UNSPECIFIED');
expect(FrankingType.STANDARD_FRANKING).toBe('STANDARD_FRANKING');
expect(FrankingType.DV_FRANKING).toBe('DV_FRANKING');
});
});
describe('ProductionCountry', () => {
it('has correct values', () => {
expect(ProductionCountry.UNSPECIFIED).toBe('UNSPECIFIED');
expect(ProductionCountry.DE).toBe('DE');
expect(ProductionCountry.AT).toBe('AT');
});
});
describe('ResponseFormat', () => {
it('has correct values', () => {
expect(ResponseFormat.FULL).toBe('FULL');
expect(ResponseFormat.SHORT).toBe('SHORT');
});
});
});

35
tsconfig.json Normal file
View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts'],
},
},
});