payload-minimax-adapter
v0.1.0
Published
MiniMax (moj.minimax.rs) invoicing adapter for Voxberg e-commerce
Maintainers
Readme
payload-minimax-adapter
MiniMax invoicing & inventory adapter for PayloadCMS ecommerce applications.
MiniMax is a Serbian cloud accounting platform providing invoicing, customer management, item catalog, stock entries and the e-invoice / SEF (eFaktura) integration. The adapter wraps the REST API at https://moj.minimax.rs/RS/API, handles OAuth2 token rotation, caches FK lookups via the shared payload-payment-shared cache, and exposes a Voxberg-shaped order-to-invoice mapper.
MiniMax is not a payment adapter — it does not implement the
PaymentAdapterinterface. It's an ERP integration consumed by Voxberg'sOrdersafterChangehooks to issue invoices for completed orders.
LIVE since 2026-03-12
The full invoice pipeline is in production:
Order created (RSD, non-fiscal) → afterChange hook → processInvoice.ts
→ MiniMax client (auth, FK lookups, customer find/create)
→ createInvoice (draft) → issueAndGeneratePdf
→ getInvoiceAttachments (PDF base64)
→ upload PDF to Vercel Blob
→ email PDF to customerPipeline location: apps/voxberg/src/collections/Invoices/processInvoice.ts. API surface fully operational: auth, invoices, items (34 products live in MiniMax), customers, stock, VAT rates, payment methods.
Live tenant info
| Field | Value |
| ------------ | ------------------------------------------------ |
| Org ID | 88675 (changed from 88898 after paid upgrade) |
| Company | BGASS LIMITED DOO BEOGRAD-ČUKARICA |
| PIB | 114041084 |
| Account type | Paid (API access enabled — confirmed 2026-03-10) |
Current version
0.1.0 (per package.json).
Installation
pnpm add payload-minimax-adapterPeer dependencies
{
"payload": "^3.81.0",
"payload-payment-shared": "workspace:*",
}payload-payment-shared (commit 0cd6307e) is a hard peer — see Shared payment runtime below.
Shared payment runtime
Despite not being a payment adapter, MiniMax shares the same cross-cutting helpers via payload-payment-shared:
| Symbol from payload-payment-shared | Used here for |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| createCache / DEFAULT_TTL_MS | Backs the FK cache for currencies, VAT rates, document numbering, payment methods. Use deleteCached(key) to invalidate after a write — the old behaviour of setCached(key, null) cached null for an hour and made newly-created MiniMax items disappear from listItems(). (Fixed in commit 81c2857a.) |
| consoleLogger / PaymentLogger | Pino-compatible logger interface. Pass req.payload.logger so quota warnings flow through Payload's structured logger; defaults to consoleLogger. |
The local MiniMaxError is kept (it predates the shared package) and carries code + statusCode fields. Shared PaymentError is not used here.
Quick start
Environment variables
# Client podaci - obtained from MiniMax support (zahtev za pomoć)
MINIMAX_CLIENT_ID=your-client-id
MINIMAX_CLIENT_SECRET=your-client-secret
# User podaci - created in Moj Profil > Nova Aplikacija
MINIMAX_USERNAME=your-app-username
MINIMAX_PASSWORD=your-app-password
# Org ID (88675 for the BGASS production tenant)
MINIMAX_ORG_ID=your-organisation-id
# Optional - defaults to https://moj.minimax.rs/RS/API
MINIMAX_API_URL=https://moj.minimax.rs/RS/APICreate the client
import { MiniMaxClient } from 'payload-minimax-adapter'
const client = new MiniMaxClient({
clientId: process.env.MINIMAX_CLIENT_ID!,
clientSecret: process.env.MINIMAX_CLIENT_SECRET!,
username: process.env.MINIMAX_USERNAME!,
password: process.env.MINIMAX_PASSWORD!,
orgId: process.env.MINIMAX_ORG_ID!,
// Optional but strongly recommended in Payload contexts:
logger: req.payload.logger,
})Create + issue an invoice
import {
MiniMaxClient,
findOrCreateCustomer,
getCurrencyByCode,
getVatRateByCode,
getDocumentNumbering,
createInvoice,
issueAndGeneratePdf,
getInvoiceAttachments,
sendEInvoice,
} from 'payload-minimax-adapter'
// 1. Resolve FK references (cached for 1h via the shared TTL cache)
const customer = await findOrCreateCustomer(client, {
name: 'Petar Petrovic',
email: '[email protected]',
isB2B: false,
})
const currency = await getCurrencyByCode(client, 'RSD')
const vatRate = await getVatRateByCode(client, 'S') // S = standard 20%
const docNumbering = await getDocumentNumbering(client)
// 2. Create draft invoice
const draft = await createInvoice(client, {
Customer: customer,
DateIssued: '2026-04-23',
DateTransaction: '2026-04-23',
DateDue: '2026-04-23',
DocumentNumbering: docNumbering,
Currency: currency,
InvoiceType: 'R', // R = invoice, P = proforma
PricesOnInvoice: 'N', // N = net prices (commit 017d7f7c)
IssuedInvoiceRows: [
{
RowNumber: 1,
ItemName: 'Proizvod ABC',
Quantity: 2,
PriceWithVAT: 1500,
VATPercent: 20,
VatRate: { ID: vatRate.ID },
UnitOfMeasurement: 'kom',
},
],
})
// 3. Issue + generate PDF in one action
const issued = await issueAndGeneratePdf(client, draft.IssuedInvoiceId, draft.RowVersion)
// 4. For B2B: send to SEF (eFaktura) — ForwardToSEF is mandatory in Serbia
// const sent = await sendEInvoice(client, issued.IssuedInvoiceId, issued.RowVersion)
// 5. Fetch the generated PDF
const attachments = await getInvoiceAttachments(client, issued.IssuedInvoiceId)Authentication
OAuth2 Resource Owner Password Credentials (grant_type=password). Two credential sets are required:
- Client podaci (
client_id+client_secret) — obtained from MiniMax support via "zahtev za pomoć". - User podaci (
username+password) — created in Moj Profil > Nova Aplikacija.
POST https://moj.minimax.rs/RS/AUT/oauth20/token
Content-Type: application/x-www-form-urlencoded
grant_type=password
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&username=YOUR_APP_USERNAME
&password=YOUR_APP_PASSWORD
&scope=minimax.rsTokens are cached in memory and automatically refreshed 60 seconds before expiry.
Warning: repeated failed auth attempts with the wrong password will lock the application. You then have to recreate it via Moj Profil.
Configuration
MiniMaxConfig
| Option | Type | Default | Description |
| -------------- | --------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| clientId | string | required | OAuth2 Client ID. |
| clientSecret | string | required | OAuth2 Client Secret. |
| username | string | required | Application username. |
| password | string | required | Application password. |
| orgId | string | required | MiniMax Organisation ID (88675 in prod). |
| apiUrl | string | https://moj.minimax.rs/RS/API | API base URL. |
| authUrl | string | https://moj.minimax.rs/RS/AUT/oauth20/token | OAuth2 token URL. |
| logger | PaymentLogger | consoleLogger | Pino-compatible logger. Pass req.payload.logger so quota warnings flow through Payload's structured logger. |
Voxberg integration
Hook trigger
// apps/voxberg/src/collections/Invoices/processInvoice.ts
// Triggered from the Orders afterChange hook when:
// currency === 'RSD' && invoiceType !== 'fiscal'Setup routes
The MiniMax integration ships with three setup/maintenance routes inside voxberg:
| Route | Purpose |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST /api/minimax/setup-items | Creates the catalog items in MiniMax (PROIZVOD / DOSTAVA, etc.). |
| POST /api/minimax/sync-skus | Aligns SKUs between Payload and MiniMax. Required: see SKU format gotcha. |
| POST /api/minimax/setup-numbering | Debug helper for document numbering + payment methods. |
Additional ops routes: /api/minimax/retry, /api/minimax/sync-stock, /api/minimax/seed-stock, /api/minimax/stock-entry.
SKU format gotcha
MiniMax uses codes like
235-0016. Payload usesVXB-*format. They do NOT line up.
Run POST /api/minimax/sync-skus after seeding new products — the route walks both catalogs and writes the MiniMax code onto the Payload variant so subsequent invoice rows reference the correct MiniMax item id. Without this, invoice creation either fails or creates a duplicate "ad-hoc" line item with no stock linkage.
Voxberg fields
Added to collections by the integration (not by this package):
Orders.invoiceType—'fiscal' | 'b2c_invoice' | 'b2b'Orders.taxNumber— PIB for B2B customersTransactions.miniMaxInvoiceId— reference to the issued MiniMax invoice id
Admin UI:
InvoiceStatus.tsx— shows MiniMax invoice status in the Orders admin panel/api/minimax/retry— manual retry for failed invoices
Invoice workflow
Standard B2C
createInvoice (POST /issuedinvoices)
↓
issueAndGeneratePdf (PUT /issuedinvoices/{id}/actions/issueAndGeneratepdf?rowVersion=…)
↓
getInvoiceAttachments (GET /issuedinvoices/{id}/attachments) → PDF base64B2B with SEF e-invoice
createInvoice
↓
issueAndGeneratePdf
↓
sendEInvoice (PUT /issuedinvoices/{id}/actions/sendEInvoice?rowVersion=…)Invoice types in Voxberg orders
| Orders.invoiceType | Behaviour |
| -------------------- | ------------------------------------------------------------------------ |
| fiscal | Skipped — fiscal receipts are handled at POS, not via MiniMax. |
| b2c_invoice | Standard invoice: create → issue + PDF. |
| b2b | Business invoice: create → issue + PDF → send to SEF (eFaktura). |
Invoice status codes
| Code | Status | Description |
| ---- | ------ | -------------------------- |
| O | Draft | Just created, editable |
| I | Issued | Finalised, PDF generated |
API reference
Client
new MiniMaxClient(config)
client.get<T>(path, params?)
client.post<T>(path, body)
client.put<T>(path, body, params?)
client.del(path)
client.action<T>(resourcePath, actionName, rowVersion)All endpoints are prefixed with /api/orgs/{orgId}/ automatically.
Invoices
| Function | Description |
| ------------------------------------------------ | -------------------------------------------- |
| createInvoice(client, data) | Create a draft invoice |
| getInvoice(client, id) | Get invoice by id |
| issueAndGeneratePdf(client, id, rowVersion) | Issue invoice and generate PDF (single call) |
| issueInvoice(client, id, rowVersion) | Issue invoice only (no PDF) |
| generatePdf(client, id, rowVersion) | Generate PDF for an already-issued invoice |
| sendEInvoice(client, id, rowVersion) | Send invoice to SEF (B2B e-invoice) |
| getInvoiceAttachments(client, id) | Get all attachments incl. generated PDF |
| cancelInvoice(client, id, rowVersion, reason?) | Cancel an issued invoice |
Customers
| Function | Description |
| ------------------------------------ | ----------------------------------------------- |
| findOrCreateCustomer(client, data) | Find by email/tax number, or create new (APR registry lookup by PIB for B2B) |
Items / catalog
| Function | Description |
| ----------------------------------------- | --------------------------------- |
| listItems(client, params?) | List catalog items |
| getItemById(client, id) | Lookup by MiniMax item id |
| getItemByCode(client, code) | Lookup by SKU/code (cached) |
| createItem(client, data) | Create item |
| findOrCreateItem(client, code, data) | Upsert by code |
Stock
| Function | Description |
| ------------------------------------- | ------------------------------------------ |
| getStockLevels(client, warehouseId?) | All stock levels (cached 15min) |
| getStockForItem(client, itemId) | Stock for a single item |
| buildStockMap(client, warehouseId?) | Code → quantity map for batch operations |
Stock entries (delivery notes)
| Function | Description |
| ------------------------------------------------- | ------------------------------------ |
| createStockEntry(client, data) | Create stock entry (priemka) |
| getStockEntry(client, id) | Get stock entry by id |
| listStockEntries(client, params?) | List stock entries |
| confirmStockEntry(client, id, rowVersion) | Confirm stock entry |
| cancelStockEntryConfirmation(client, id, rv) | Cancel confirmation |
| deleteStockEntry(client, id) | Delete (only drafts) |
| getDeliveryNotePdf(client, id) | Download delivery note PDF |
Warehouses
| Function | Description |
| --------------------------------- | ----------------------------------- |
| listWarehouses(client) | All warehouses |
| getWarehouseById(client, id) | Lookup by id |
| getWarehouse(client) | Convenience: first/default warehouse |
Cached FK lookups (1h TTL via shared cache)
| Function | Description |
| --------------------------------------- | -------------------------------------------- |
| getCurrencyByCode(client, code) | Currency FK (e.g. 'RSD'). |
| getVatRateByCode(client, code, date?) | VAT rate FK (e.g. 'S' = 20%). |
| getDocumentNumbering(client) | First available document numbering. |
| getPaymentMethods(client) | Available payment methods. |
| getCountryByCode(client, code) | Country FK lookup. |
| clearCache() | Clear all cached FK lookups (use after writes). |
Order mapper
| Function | Description |
| ------------------------------------ | ----------------------------------------- |
| orderToMiniMaxInvoice(order, refs) | Map a Voxberg Order to MiniMax invoice data. Sale discount applied to product rows only — never to shipping/COD (commit 44b4f325). |
Important MiniMax API conventions
Boolean values
MiniMax uses string booleans: "D" = Yes (Da), "N" = No (Ne). Never true/false.
PricesOnInvoice: 'N' // Net prices (post commit 017d7f7c)
SubjectToVAT: 'N' // Not subject to VAT
ForwardToSEF: 'D' // MANDATORY for all Serbia invoices (commit c4b61916)Row Version (optimistic locking)
All actions (issue, generatePdf, sendEInvoice, cancel) require a rowVersion query parameter — a concurrency token returned with every response. Always pass the latest RowVersion from the previous API response.
const draft = await createInvoice(client, data) // RowVersion = "AAA..."
const issued = await issueAndGeneratePdf(client, draft.IssuedInvoiceId, draft.RowVersion)
// issued.RowVersion is now "BBB..." for the next actionFK references
Foreign-key references use { ID: number, Name?: string }:
Customer: { ID: 12345, Name: 'Petar Petrovic' }
Currency: { ID: 1 }
VatRate: { ID: 3 }
DocumentNumbering: { ID: 1 }Use the cached lookup helpers above to resolve these.
Endpoints
All paths are prefixed with /api/orgs/{orgId}/ by the client:
| Path | Description |
| ---------------------------- | ---------------------------------------- |
| /issuedinvoices | Issued invoices CRUD |
| /customers | Customers CRUD |
| /customers/by-tax-number | Create customer from APR registry by PIB |
| /currencies/by-code/{code} | Currency lookup |
| /vat-rates/by-code/{code} | VAT rate lookup |
| /document-numbering | Document numbering configs |
| /payment-methods | Payment methods |
| /items / /items/by-code/{code} | Catalog items |
| /warehouses | Warehouses |
| /stocks | Stock levels |
| /stockentries | Stock entries (priemka) |
Rate limits
| Limit | Value | | --------------------- | ---------- | | Daily calls per org | 1,000 | | Monthly calls per org | 20,000 |
The client tracks daily calls and emits a structured warning at 800 via logger.warn. Plan accordingly:
- Use cached FK lookups (currency, VAT rate, doc numbering) to reduce calls.
- Batch operations where possible.
- Each invoice typically takes 3–5 API calls (lookups + create + issue).
Caveat: the in-memory counter resets every 24h since the first call, not at midnight UTC. On serverless /
next devrestarts the counter resets to 0, so warnings may never fire. A Payload-persistedMiniMaxUsageglobal is on the roadmap.
Error handling
import { MiniMaxError } from 'payload-minimax-adapter'
try {
await issueAndGeneratePdf(client, invoiceId, rowVersion)
} catch (error) {
if (error instanceof MiniMaxError) {
console.error(error.code) // 'AUTH_FAILED', 'HTTP_404', etc.
console.error(error.statusCode) // 401, 404, etc.
console.error(error.message)
}
}| Code | Description |
| -------------------- | --------------------------------- |
| AUTH_FAILED | OAuth2 authentication failed |
| CURRENCY_NOT_FOUND | Currency code not found |
| VAT_RATE_NOT_FOUND | VAT rate code not found |
| NO_DOC_NUMBERING | No document numbering configured |
| HTTP_{status} | API returned non-OK status |
| MINIMAX_ERROR | Generic / wrapped error |
TypeScript
Full type surface re-exported from ./types:
import type {
MiniMaxConfig,
MiniMaxRef,
MiniMaxInvoiceData,
MiniMaxInvoice,
MiniMaxInvoiceRow,
MiniMaxPaymentMethodEntry,
MiniMaxDocumentAttachment,
MiniMaxAttachment,
MiniMaxCustomerData,
MiniMaxCustomer,
MiniMaxListResponse,
MiniMaxDocumentNumbering,
MiniMaxCurrency,
MiniMaxVatRate,
MiniMaxPaymentMethod,
MiniMaxCountry,
MiniMaxItem,
MiniMaxItemData,
MiniMaxItemFull,
MiniMaxWarehouse,
MiniMaxStockLevel,
MiniMaxStockEntryData,
MiniMaxStockEntry,
MiniMaxStockEntryRow,
OrderData,
AddressData,
} from 'payload-minimax-adapter'Recent changes
| Commit | Change |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| 81c2857a | P0 cache invalidation fix (deleteCached instead of setCached(null)), P1 structured logger, switch to shared cache, e2e skip guard. |
| 44b4f325 | Apply sale discount to product rows only — never to shipping/COD lines. |
| 2209d361 | Convert package to ESM-first (type: module). |
| 589d6aba | Convert cents → currency units in the invoice mapper. |
| cc89af0c | Resolve invoice item prices from populated product/variant. |
| 6b7dea04 | Use Tekući račun (bank transfer) payment method — no fiscal numbering needed. |
| dd467b7a | Send empty payment-methods array to prevent MiniMax fiscal default. |
| c4b61916 | ForwardToSEF mandatory for all Serbian invoices. |
| 017d7f7c | Switch to net prices (PricesOnInvoice='N'), drop StocksAccount from items. |
| 5d1f2331 | Add AlreadyPaid field to payment method entry. |
| 2026-03-12 | Pipeline went LIVE — processInvoice.ts orchestrates MiniMax → PDF → Vercel Blob → email. |
Getting MiniMax credentials
User podaci
- Log in at MiniMax RS.
- Moj Profil > Uredi osnovne podatke.
- Click Nova aplikacija at the bottom.
- Enter application name, username, password.
- These become
MINIMAX_USERNAME+MINIMAX_PASSWORD.
Client podaci
- Contact MiniMax support via zahtev za pomoć.
- Request API client credentials for external integration.
- You receive
client_id+client_secret. - These become
MINIMAX_CLIENT_ID+MINIMAX_CLIENT_SECRET.
Organisation ID
Either look it up in the MiniMax URL or call GET /api/currentuser/orgs to list accessible organisations. Production tenant is 88675.
License
MIT © blaze IT s.r.o.
