ebm-rw
v1.3.0
Published
TypeScript client SDK for Rwanda Revenue Authority's EBM 2.1 / VSDC tax API
Maintainers
Readme
ebm-rw
A typed TypeScript/JavaScript client SDK for the Rwanda Revenue Authority (RRA) EBM 2.1 / VSDC tax API — the fiscalization interface POS and CIS/ERP software must integrate with to register sales, purchases, stock movements, and items with RRA.
Built directly against RRA's own VSDC reference implementation and specification documents — every request/response shape below is verified against a running VSDC instance, not just the written spec.
Official RRA Documentation
📄 VSDC Specification Document (v1.0.4, 2022) https://www.rra.gov.rw/fileadmin/user_upload/vsdc_specification_document_v1.0.4__2022.pdf
📄 OSDC (Online Sales Data Controller) Documentation (v1.0.1, 2022) https://www.rra.gov.rw/fileadmin/user_upload/osdc_documentation_v1.0.1_2022-04-08.pdf
📄 RRA Easy Adoption Guide on EBM https://www.rra.gov.rw/fileadmin/user_upload/rra_easy_adoption_on_ebm_engl.pdf
⚠️ Device/branch registration and VSDC approval with RRA (via the MyRRA portal) are required before any of the operations below will succeed against RRA's systems.
Installation
npm install ebm-rwQuick start
import { EbmClient, buildItemCd } from "ebm-rw";
const client = new EbmClient({ baseUrl: "https://your-vsdc-host" });
const init = await client.selectInitInfo({
tin: "999991130",
bhfId: "00",
dvcSrlNo: "my-device-001",
});
const itemCd = buildItemCd({
originNationCd: "RW",
productTypeCd: "2",
pkgUnitCd: "NT",
qtyUnitCd: "BA",
sequence: 1,
});
await client.saveItem({
tin: "999991130",
bhfId: "00",
itemClsCd: "...",
itemCd,
itemTyCd: "2",
itemNm: "Example product",
orgnNatCd: "RW",
pkgUnitCd: "NT",
qtyUnitCd: "BA",
taxTyCd: "B",
dftPrc: 1000,
isrcAplcbYn: "N",
useYn: "Y",
regrNm: "Admin",
regrId: "admin",
modrNm: "Admin",
modrId: "admin",
});Hosted vs. local (offline-first) usage
EbmClient only needs a reachable VSDC base URL — it has no built-in opinion on where that VSDC instance lives. That's what makes offline-first deployments possible:
- Hosted: point
baseUrlat a cloud-hosted VSDC instance (typically fronted by your own backend, which adds auth, outbox durability, and multi-tenant scoping — this SDK itself is still a thin, stateless HTTP client and doesn't implement any of that). Requires internet connectivity. - Local: point
baseUrlat a VSDC instance running on the same local network as the POS, e.g.http://192.168.1.71:8080. No internet required for day-to-day fiscalization — only VSDC's own periodic sync back to RRA needs connectivity, and that's VSDC's responsibility, not this SDK's.
const client = new EbmClient({
baseUrl: settings.mode === "local" ? settings.localVsdcUrl : "https://your-hosted-vsdc-host",
});Retry/backoff (opt-in)
EbmClient can optionally retry a single call's transient failures — a fetch() throw (network unreachable, DNS failure, timeout) or an HTTP 5xx response — with exponential backoff and jitter. It's off by default (zero behavior change unless you opt in), never retries HTTP 4xx responses, and never retries a business-logic failure (a parsed response with a non-success resultCd, e.g. "994" duplicate) — those still throw on the first attempt. This is still just a bounded, stateless retry of one HTTP call, not outbox/durability: that's still the embedding service's job.
const client = new EbmClient({
baseUrl: settings.localVsdcUrl,
retry: { attempts: 2, baseDelayMs: 250, maxDelayMs: 5000 }, // all fields optional; attempts defaults to 0
});dvcSrlNo must be sent byte-exact. RRA compares it exactly as registered — leading/trailing whitespace is significant. A stripped or re-typed value that looks identical will fail selectInitInfo with resultCd: 901 "It is not valid device", indistinguishable from a genuinely unapproved device.
Full API reference
Every VSDC request carries tin and bhfId ("00" = head office). Every response is wrapped in the same envelope unless noted:
interface EbmEnvelope<TData> {
resultCd: string;
resultMsg: string;
resultDt: string; // yyyyMMddhhmmss
data: TData;
}"Save"-style requests also carry audit fields: regrNm, regrId (registrant name/ID) and modrNm, modrId (modifier name/ID).
Initialization
selectInitInfo — one-time device activation
Call once per physical/virtual device, before anything else. Returns the taxpayer/branch/device record RRA holds for this dvcSrlNo, including the cmcKey your integration needs to persist for subsequent calls.
await client.selectInitInfo({
tin: "999991130",
bhfId: "00",
dvcSrlNo: "my-device-001",
});
// => { resultCd, resultMsg, resultDt, data: { info: {
// tin, taxprNm, bsnsActv, // taxpayer
// bhfId, bhfNm, bhfOpenDt, prvncNm, dstrtNm, sctrNm,
// locDesc, hqYn, mgrNm, mgrTelNo, mgrEmail, // branch
// dvcId, sdcId, mrcNo, intrlKey, signKey, cmcKey,
// lastSaleInvcNo, lastPchsInvcNo, lastSaleRcptNo, ... // device
// } } }Reference / code data
selectCodes — pull RRA's common code/classification lists
await client.selectCodes({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { clsList: [{ cdCls, cdClsNm, cdClsDesc, useYn, dtlList: [{ cd, cdNm, cdDesc, srtOrd, useYn }] }] } }selectItemsClass — pull the item classification tree (itemClsCd values)
await client.selectItemsClass({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { itemClsList: [{ itemClsCd, itemClsNm, itemClsLvl, taxTyCd, mjrTgYn, useYn }] } }selectCustomer — look up a taxpayer by TIN
await client.selectCustomer({ tin, bhfId, custmTin: "104345680" });
// => { data: { custList: [{ tin, taxprNm, taxprSttsCd, prvncNm, dstrtNm, sctrNm, locDesc }] } }selectBranches — pull your own registered branch offices
await client.selectBranches({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { bhfList: [{ tin, bhfId, bhfNm, bhfSttsCd, prvncNm, dstrtNm, sctrNm, locDesc, mgrNm, mgrTelNo, mgrEmail, hqYn }] } }selectNotices — pull RRA notices/announcements
await client.selectNotices({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { noticeList: [{ noticeNo, title, cont, dtlUrl, regrNm, regDt }] } }Branch info
saveBranchCustomer — register a repeat/known customer against your branch
await client.saveBranchCustomer({
tin, bhfId,
custNo: "CUST-0001",
custTin: "104345680",
custNm: "Acme Ltd",
useYn: "Y",
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }saveBranchUser — register a POS/branch user (e.g. a cashier)
await client.saveBranchUser({
tin, bhfId,
userId: "cashier01",
userNm: "Jane Doe",
pwd: "...",
useYn: "Y",
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }saveBranchInsurance — pharmacy taxpayers only
await client.saveBranchInsurance({
tin, bhfId,
isrccCd: "01",
isrccNm: "RSSB",
isrcRt: 10.0,
useYn: "Y",
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }Items
saveItem — register/update a product in your catalog
await client.saveItem({
tin, bhfId,
itemClsCd: "5020230201",
itemCd, // build with buildItemCd() — see below
itemTyCd: "2", // 1 Raw Material, 2 Finished Product, 3 Service, 4 Assets, 5 Expenses
itemNm: "500ml bottled water",
orgnNatCd: "RW",
pkgUnitCd: "NT",
qtyUnitCd: "BA",
taxTyCd: "B", // B = 18% standard rate
dftPrc: 500,
isrcAplcbYn: "N",
useYn: "Y",
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }saveItemComposition — define a bundle/recipe (BOM) for a composite item
await client.saveItemComposition({
tin, bhfId,
itemCd: "parentItemCd",
cpstItemCd: "componentItemCd",
cpstQty: 2,
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }selectItems — pull your registered catalog (including RRA-side reclassifications)
await client.selectItems({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { itemList: [{ itemClsCd, itemCd, itemTyCd, itemNm, orgnNatCd, pkgUnitCd,
// qtyUnitCd, taxTyCd, dftPrc, isrcAplcbYn, rraModYn, useYn, ... }] } }Import items
selectImportItems — pull customs-declared items awaiting confirmation
await client.selectImportItems({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { itemList: [{ taskCd, dclDe, itemSeq, dclNo, hsCd, itemNm, imptItemsttsCd,
// orgnNatCd, exptNatCd, pkg, pkgUnitCd, qty, qtyUnitCd, ... }] } }updateImportItems — confirm an imported item into your own catalog
await client.updateImportItems({
tin, bhfId,
taskCd: "...",
dclDe: "20260101",
itemSeq: 1,
hsCd: "...",
itemClsCd: "5020230201",
itemCd: "...",
imptItemSttsCd: "3",
modrNm: "Admin", modrId: "admin",
});
// => { data: null }Sales
saveSales — the core invoicing call: submit a sale, get back the fiscal receipt signature
await client.saveSales({
tin, bhfId,
invcNo: 1024, // your own sequential invoice number, per branch
orgInvcNo: 0, // 0 for a new sale; original invcNo when refunding/adjusting
rcptTyCd: "S", // S = normal Sale, R = Refund
salesTyCd: "N", // always "N" per RRA guidance
pmtTyCd: "01", // 01 CASH .. 06 MOBILE MONEY .. 07 OTHER
salesSttsCd: "02",
cfmDt: "20260101120000",
salesDt: "20260101",
totItemCnt: 1,
taxblAmtA: 0, taxblAmtB: 500, taxblAmtC: 0, taxblAmtD: 0,
taxRtA: 0, taxRtB: 18, taxRtC: 0, taxRtD: 0,
taxAmtA: 0, taxAmtB: 90, taxAmtC: 0, taxAmtD: 0,
totTaxblAmt: 500,
totTaxAmt: 90,
totAmt: 590,
prchrAcptcYn: "N",
// custTin/custNm: "104345680"/"ACME Ltd" — when the buyer is a business (has a TIN), RRA spec
// v1.0.5 §3.3.6.1 requires prcOrdCd (Purchase Order Code, max 6 chars) too; omit both for
// individual/walk-in customers.
receipt: { rptNo: 1, prchrAcptcYn: "N" },
itemList: [{
itemSeq: 1,
itemCd,
itemNm: "500ml bottled water",
pkgUnitCd: "NT", pkg: 1,
qtyUnitCd: "BA", qty: 1,
prc: 590, splyAmt: 590, dcRt: 0, dcAmt: 0,
taxTyCd: "B", taxblAmt: 500, totTaxAmt: 90, totAmt: 590,
}],
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: { rcptNo, intrlData, rcptSign, totRcptNo, vsdcRcptPbctDate, sdcId, mrcNo } }
// Print intrlData/rcptSign on the physical receipt — this is the fiscal proof of sale.resendSales — manually re-trigger delivery for a previously-submitted sale
await client.resendSales({ /* see INTEGRATION_GUIDE.md for when to use this */ });Purchases
selectTrnsPurchaseSales — pull sales suppliers have declared against you as buyer
await client.selectTrnsPurchaseSales({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { saleList: [{ spplrTin, spplrNm, spplrBhfId, spplrInvcNo, rcptTyCd, cfmDt,
// salesDt, totItemCnt, totTaxblAmt, totTaxAmt, totAmt, itemList: [...] }] } }savePurchases — confirm/record a purchase transaction
await client.savePurchases({
tin, bhfId,
invcNo: 501,
orgInvcNo: 0,
regTyCd: "M", // Manual
pchsTyCd: "N",
rcptTyCd: "P",
pmtTyCd: "01",
pchsSttsCd: "02",
pchsDt: "20260101",
totItemCnt: 1,
taxblAmtA: 0, taxblAmtB: 500, taxblAmtC: 0, taxblAmtD: 0,
taxRtA: 0, taxRtB: 18, taxRtC: 0, taxRtD: 0,
taxAmtA: 0, taxAmtB: 90, taxAmtC: 0, taxAmtD: 0,
totTaxblAmt: 500, totTaxAmt: 90, totAmt: 590,
itemList: [{
itemSeq: 1, itemClsCd: "5020230201", itemNm: "500ml bottled water",
pkg: 1, qtyUnitCd: "BA", qty: 1, prc: 590, splyAmt: 590,
dcRt: 0, dcAmt: 0, taxblAmt: 500, taxTyCd: "B", taxAmt: 90, totAmt: 590,
}],
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }Stock
selectStockItems — pull stock movement between HQ/branches
await client.selectStockItems({ tin, bhfId, lastReqDt: "20260101000000" });
// => { data: { stockList: [{ custTin, custBhfId, sarNo, ocrnDt, totItemCnt,
// totTaxblAmt, totTaxAmt, totAmt, itemList: [...] }] } }saveStockItems — record a stock in/out event
await client.saveStockItems({
tin, bhfId,
sarNo: 1, orgSarNo: 0,
regTyCd: "M",
sarTyCd: "02", // stock in/out type code
ocrnDt: "20260101",
totItemCnt: 1,
totTaxblAmt: 500, totTaxAmt: 90, totAmt: 590,
itemList: [{
itemSeq: 1, itemClsCd: "5020230201", itemNm: "500ml bottled water",
pkgUnitCd: "NT", pkg: 1, qtyUnitCd: "BA", qty: 10,
prc: 590, splyAmt: 5900, totDcAmt: 0,
taxblAmt: 5000, taxTyCd: "B", taxAmt: 900, totAmt: 5900,
}],
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }saveStockMaster — push current on-hand quantity for an item
await client.saveStockMaster({
tin, bhfId,
itemCd,
rsdQty: 42,
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }Z-reports (daily fiscal summary)
saveZReport — submit the end-of-day summary
await client.saveZReport({
tin, bhfId,
zRepDt: "20260101",
totRcptNo: 87,
totSaleAmt: 452300,
totTaxAmt: 68998,
regrNm: "Admin", regrId: "admin", modrNm: "Admin", modrId: "admin",
});
// => { data: null }checkZReport — verify a previously-submitted day's summary
await client.checkZReport({ tin, bhfId, zRepDt: "20260101" });
// => { data: { reportList: [{ zRepDt, totRcptNo, totSaleAmt, totTaxAmt }] } }Utility / connectivity
selectServerTime — bare liveness check
await client.selectServerTime();
// => "20260101120000" (plain string, not the usual envelope)echoTest — post-initialization connectivity check
Requires a device that has already completed selectInitInfo successfully.
await client.echoTest({ tin, bhfId, echoMsg: "ping" });
// => { data: { echoMsg: "ping" } }Sector-specific / newer operations
These were added in a newer VSDC build and only apply to fuel-station or hospitality/tourism taxpayers. Every other operation above is stable and applies to all sectors.
getFuelProducts — look up recognized fuel product codes (fuel/petroleum sector)
await client.getFuelProducts({ tin, bhfId });saveRrpChangeHistory — record a Recommended Retail Price change (fuel/petroleum sector)
await client.saveRrpChangeHistory({ /* see JSDoc on RrpChangeHistSaveReq for fields */ });Fuel/hospitality-sector fields also appear as optional fields on saveItem (rrp, ttCatCd, propertyTyCd, roomTypeCd, currentStockQty) and saveSales (taxblAmtTt/taxRtTt/taxAmtTt tourism tax buckets, rrp per line item) — omit them entirely for non-fuel/non-hospitality businesses.
Error handling
Every non-success resultCd throws EbmApiError:
import { EbmApiError } from "ebm-rw";
try {
await client.saveSales({ /* ... */ });
} catch (err) {
if (err instanceof EbmApiError) {
console.error(err.resultCd, err.resultMsg);
if (err.isDuplicate) {
// resultCd 994 — already delivered on a previous attempt, safe to treat as success
} else if (err.isClientSide) {
// 8xx-series — request never reached RRA's business logic (network/formatting issue)
}
}
}describeResultCode(resultCd) gives a human-readable label for any code. See the VSDC Specification PDF linked above for the authoritative code table.
What's covered
All VSDC operations documented above — initialization, code/reference-data sync, branch/customer/item/stock/import-item CRUD, sales, purchases, Z-reports, connectivity checks — plus itemCd build/parse helpers (buildItemCd/parseItemCd) and date-format helpers for the date string formats VSDC uses.
Some request/response shapes (saveItemComposition, saveZReports/checkZReport) were reconstructed from the reference implementation rather than fully-documented spec text — flagged with a comment in src/types/ and worth confirming against a live sandbox call before depending on them in production.
Live verification
scripts/liveCheck.ts is a manual, opt-in smoke test against a real/sandbox VSDC — never run in CI,
never wired into any app's runtime. It reads tin/bhfId/dvcSrlNo/VSDC_BASE_URL from EBM/.env
(copy EBM/.env.example and fill in real values first):
npm run live:codes # confirms /code/selectCodes; diff its output against itemCode.ts's/pos-cloud's
# ebmUnitCodes.ts's hardcoded tables after any spec-drift concern
npm run live:init # confirms /initializer/selectInitInfoNever run live:init against a production dvcSrlNo. RRA only ever returns the one-time
device-init payload (cmcKey, sdcId, mrcNo, lastSaleInvcNo, etc.) on a device's first-ever
successful (resultCd '000') call — every call after that returns resultCd '902' ("already
installed") with the payload absent. Re-running this against an already-activated device is safe
(it'll just report 902), but there is no "undo" if it's ever run against a device that hasn't
been activated yet and shouldn't be.
Documentation
See INTEGRATION_GUIDE.md for a practical guide to which operation to call for which real-world POS event (checkout, refund, day close, receiving stock, etc.).
License
MIT
