From 534438e2adc3da8fa10f49736311b3f4dbdb2e38 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Thu, 14 May 2026 02:40:52 +0300 Subject: [PATCH] fix: correct DocumentType enum, numeric Language, drop empty Payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world smoke-test against SUMIT's /accounting/documents/create/ surfaced four divergences between 0.3.0 and what SUMIT actually accepts: 1. DocumentType=1 created a חשבונית מס-קבלה (Tax Invoice-Receipt), not the pre-payment "חשבון עסקה" we claimed. The correct value for ProformaInvoice is 3, per SUMIT's Accounting_Typed_DocumentType enum. SUMIT_DOCUMENT_TYPE is now expanded to cover the full enum; the incorrect TransactionInvoice=1 constant is retained as a deprecated alias for backwards compatibility. 2. Details.Language as a string ("he"/"en") was rejected with: "Details.Language: Error converting value \"he\"" SUMIT's Accounting_Typed_Language is a numeric enum (0=Hebrew, 1=English, 2=Arabic, 3=Spanish). The helper now accepts either a numeric code or the shorthand strings and converts. New SUMIT_LANGUAGE constant exposes the enum. 3. Empty Payments arrays were rejected on document creation. The payload now omits the Payments key entirely. 4. Customer.SearchMode was hardcoded to 0, which prevented upserts. It's now derived: SUMIT id -> 1, ExternalIdentifier -> 2, else 0. Explicit caller value still wins. Also strips whitespace-only optional fields (emailAddress, phone, taxId, item description, sku, etc.) — SUMIT rejects empty strings on several optional fields rather than treating them as absent. 9 new tests; 27 total passing. Zero new runtime dependencies. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++ README.md | 8 ++- docs/API_REFERENCE.md | 8 +-- src/index.test.ts | 123 ++++++++++++++++++++++++++++++++++--- src/index.ts | 138 ++++++++++++++++++++++++++++++++---------- 5 files changed, 252 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf103b..3e2cd97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this package are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-05-14 + +### Fixed + +- `buildCreateDocumentPayload` no longer emits `Payments: []` — SUMIT rejected document-create requests that included an empty `Payments` array. +- `Details.Language` is now sent as the numeric enum SUMIT requires (`Accounting_Typed_Language`: Hebrew=0, English=1, Arabic=2, Spanish=3) rather than the literal `"he"`/`"en"` string that returned `Details.Language: Error converting value "he"` from SUMIT. +- `Customer.SearchMode` is now derived automatically: SUMIT id ⇒ `1`, ExternalIdentifier ⇒ `2`, otherwise `0`. Previously every request hardcoded `0`, which prevented customer upserts. +- Empty / whitespace-only optional fields (`emailAddress`, `phone`, `taxId`, item `description`, `sku`, etc.) are now stripped rather than sent as `""` — SUMIT rejects empty strings on several optional fields. + +### Added + +- `SUMIT_DOCUMENT_TYPE` now exposes the full `Accounting_Typed_DocumentType` enum: `Invoice` (0), `InvoiceAndReceipt` (1), `Receipt` (2), `ProformaInvoice` (3), `PriceQuotation` (12), and all credit/expense variants. +- `SUMIT_LANGUAGE` const exposing `Hebrew=0`, `English=1`, `Arabic=2`, `Spanish=3`. +- `language` / `responseLanguage` params accept the shorthand strings `"he"`/`"en"`/`"ar"`/`"es"` (and full English names) in addition to numeric codes. + +### Changed + +- `SumitNormalizedEventType` and supporting types unchanged. +- `SUMIT_DOCUMENT_TYPE.TransactionInvoice` is retained as a deprecated alias to `1` for backwards compatibility, but **its meaning was wrong in 0.3.0** — code `1` is `InvoiceAndReceipt` (חשבונית מס-קבלה), not the pre-payment "חשבון עסקה". Use `SUMIT_DOCUMENT_TYPE.ProformaInvoice` (3) for חשבון עסקה. + ## [0.3.0] - 2026-05-13 ### Added diff --git a/README.md b/README.md index a7596b5..78cd4e8 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ import { buildCreateDocumentPayload, SUMIT_DOCUMENT_TYPE } from "sumit-api"; const payload = buildCreateDocumentPayload({ companyId: 123, apiKey: process.env.SUMIT_API_KEY!, - documentType: SUMIT_DOCUMENT_TYPE.TransactionInvoice, // 1 + documentType: SUMIT_DOCUMENT_TYPE.ProformaInvoice, // 3 — חשבון עסקה customer: { externalIdentifier: "client_42", name: "Acme Ltd", @@ -135,7 +135,11 @@ const payload = buildCreateDocumentPayload({ }); ``` -`SUMIT_DOCUMENT_TYPE` only lists values this package has actively verified. SUMIT exposes many more document type codes — pass any number directly via the `documentType` field. +`SUMIT_DOCUMENT_TYPE` covers SUMIT's `Accounting_Typed_DocumentType` enum — `Invoice` (0, חשבונית מס), `InvoiceAndReceipt` (1, חשבונית מס-קבלה), `Receipt` (2, קבלה), `ProformaInvoice` (3, חשבון עסקה), `PriceQuotation` (12, הצעת מחיר), credit/expense variants, and more. Pass any numeric code directly via `documentType` if needed. + +`language` accepts either a `SUMIT_LANGUAGE` numeric code or the shorthand strings `"he"`/`"en"`/`"ar"`/`"es"` (and their full English names) — the helper converts to the numeric enum SUMIT requires. Unknown strings are dropped silently. + +`customer.searchMode` is derived automatically when omitted: SUMIT id `1`, ExternalIdentifier `2`, otherwise `0` (create new). Pass an explicit value to override. --- diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 72bbb06..9d478a4 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -123,7 +123,7 @@ Issues a SUMIT accounting document (חשבון עסקה / חשבונית מס / { Credentials: { CompanyID, APIKey }, Details: { - Type: number, // SUMIT document type code; 1 = חשבון עסקה + Type: number, // SUMIT document type code; 3 = חשבון עסקה (ProformaInvoice) Customer: { SearchMode: 0 | 1 | 2, // 0 = match by ID (default for this endpoint) Name: string, @@ -138,7 +138,7 @@ Issues a SUMIT accounting document (חשבון עסקה / חשבונית מס / NoVAT?: boolean, }, SendByEmail?: { EmailAddress, Original, SendAsPaymentRequest }, - Language?: string, // e.g. "he" / "en" + Language?: number, // Accounting_Typed_Language enum: 0=Hebrew, 1=English, 2=Arabic, 3=Spanish Currency?: "ILS" | "USD" | "EUR", Description?: string, ExternalReference?: string, @@ -159,11 +159,11 @@ Issues a SUMIT accounting document (חשבון עסקה / חשבונית מס / SearchMode: 0 | 1 | 2, }, }], - Payments: [], + // Payments omitted — SUMIT rejects empty Payments arrays on document creation. VATIncluded: boolean, VATPerItem?: boolean, VATRate?: number, - ResponseLanguage?: string, + ResponseLanguage?: number, } ``` diff --git a/src/index.test.ts b/src/index.test.ts index 0e4c16e..ab72f71 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -350,7 +350,7 @@ describe("@deepclaw/sumit", () => { const payload = buildCreateDocumentPayload({ companyId: 123, apiKey: "api-key", - documentType: SUMIT_DOCUMENT_TYPE.TransactionInvoice, + documentType: SUMIT_DOCUMENT_TYPE.ProformaInvoice, customer: { externalIdentifier: "client-1", name: "אקמה בע״מ", @@ -378,15 +378,15 @@ describe("@deepclaw/sumit", () => { expect(payload).toEqual({ Credentials: { CompanyID: 123, APIKey: "api-key" }, Details: { - Type: 1, + Type: 3, Customer: { - SearchMode: 0, + SearchMode: 2, // derived from externalIdentifier Name: "אקמה בע״מ", EmailAddress: "billing@example.invalid", ExternalIdentifier: "client-1", CompanyNumber: "514999000", }, - Language: "he", + Language: 0, // Hebrew Currency: "ILS", }, Items: [ @@ -403,16 +403,125 @@ describe("@deepclaw/sumit", () => { Item: { Name: "שעות פיתוח", SearchMode: 0 }, }, ], - Payments: [], VATIncluded: false, }); }); + it("omits Payments key entirely (SUMIT rejects empty Payments arrays on document creation)", () => { + const payload = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C" }, + items: [{ name: "Item", unitPrice: 10 }], + }); + expect("Payments" in payload).toBe(false); + }); + + it("derives Customer.SearchMode: 0 default, 2 with externalIdentifier, 1 with id", () => { + const a = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "A" }, + items: [{ name: "Item", unitPrice: 1 }], + }); + expect(a.Details.Customer.SearchMode).toBe(0); + + const b = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "B", externalIdentifier: "ext-1" }, + items: [{ name: "Item", unitPrice: 1 }], + }); + expect(b.Details.Customer.SearchMode).toBe(2); + + const c = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C", id: "sumit-cust-1" }, + items: [{ name: "Item", unitPrice: 1 }], + }); + expect(c.Details.Customer.SearchMode).toBe(1); + + const d = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "D", externalIdentifier: "ext-1", searchMode: 0 }, + items: [{ name: "Item", unitPrice: 1 }], + }); + expect(d.Details.Customer.SearchMode).toBe(0); // explicit caller value wins + }); + + it("converts language strings to SUMIT numeric enum and drops unknown values", () => { + const he = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C" }, + items: [{ name: "I", unitPrice: 1 }], + language: "he", + }); + expect(he.Details.Language).toBe(0); + + const en = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C" }, + items: [{ name: "I", unitPrice: 1 }], + language: "English", + }); + expect(en.Details.Language).toBe(1); + + const numeric = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C" }, + items: [{ name: "I", unitPrice: 1 }], + language: 2, + }); + expect(numeric.Details.Language).toBe(2); + + const unknown = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { name: "C" }, + items: [{ name: "I", unitPrice: 1 }], + language: "klingon", + }); + expect(unknown.Details.Language).toBeUndefined(); + }); + + it("trims whitespace and drops empty strings from customer/item optional fields", () => { + const payload = buildCreateDocumentPayload({ + companyId: 1, + apiKey: "k", + documentType: 3, + customer: { + name: "C", + emailAddress: " ", + phone: "", + taxId: " 514999000 ", + }, + items: [{ name: "I", description: "", unitPrice: 10 }], + }); + expect(payload.Details.Customer.EmailAddress).toBeUndefined(); + expect(payload.Details.Customer.Phone).toBeUndefined(); + expect(payload.Details.Customer.CompanyNumber).toBe("514999000"); + expect(payload.Items[0].Item.Description).toBeUndefined(); + }); + it("includes SendByEmail when requested and maps currency strings", () => { const payload = buildCreateDocumentPayload({ companyId: 7, apiKey: "k", - documentType: 1, + documentType: 3, customer: { name: "C" }, items: [{ name: "Item", unitPrice: 10 }], currency: "USD", @@ -476,7 +585,7 @@ describe("@deepclaw/sumit", () => { const payload = buildCreateDocumentPayload({ companyId: 1, apiKey: "super-secret", - documentType: 1, + documentType: 3, customer: { name: "C", emailAddress: "c@example.invalid" }, items: [{ name: "Item", unitPrice: 10 }], }); diff --git a/src/index.ts b/src/index.ts index 3a2c81a..80bdc62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,14 +9,63 @@ export type SumitNormalizedEventType = | "document.failed" | "sumit.trigger.unmapped"; -// Known SUMIT document type codes. `TransactionInvoice` (1) is the -// pre-payment "חשבון עסקה". Other numeric codes (tax invoice, receipt, -// tax-invoice-receipt, etc.) accepted by SUMIT can be passed as plain -// numbers — this object only lists values we have actively verified. +// SUMIT document type codes from `Accounting_Typed_DocumentType` in the +// official OpenAPI spec. Pass any of these (or any other numeric code SUMIT +// supports) via `documentType`. +// +// Earlier (`0.3.0`) this object exported `TransactionInvoice = 1`, which was +// wrong — code `1` is a Tax Invoice-Receipt (חשבונית מס-קבלה). The pre-payment +// "חשבון עסקה" is `ProformaInvoice = 3`. `TransactionInvoice` is kept as a +// deprecated alias for backwards compatibility; new code should use the +// explicit names below. export const SUMIT_DOCUMENT_TYPE = { + Invoice: 0, // חשבונית מס + InvoiceAndReceipt: 1, // חשבונית מס-קבלה + Receipt: 2, // קבלה + ProformaInvoice: 3, // חשבון עסקה + DonationReceipt: 4, + CreditInvoice: 5, + CreditInvoiceAndReceipt: 6, + CreditReceipt: 7, + Order: 8, + DeliveryNote: 9, + GoodsReturnNote: 10, + PurchasingOrder: 11, + PriceQuotation: 12, // הצעת מחיר + PaymentRequest: 13, + CreditDonationReceipt: 14, + ExpenseInvoiceReceipt: 15, + ExpenseInvoice: 16, + ExpenseReceipt: 17, + ExpenseRequest: 18, + CreditExpenseInvoiceReceipt: 19, + CreditExpenseInvoice: 20, + CreditExpenseReceipt: 21, + SupplierPayment: 22, + /** @deprecated Wrong in 0.3.0; was InvoiceAndReceipt. Use `ProformaInvoice` for חשבון עסקה. */ TransactionInvoice: 1, } as const; +// SUMIT language enum (`Accounting_Typed_Language`). The documents endpoint +// expects a number, not a string. +export const SUMIT_LANGUAGE = { + Hebrew: 0, + English: 1, + Arabic: 2, + Spanish: 3, +} as const; + +const LANGUAGE_STRING_TO_CODE: Record = { + he: 0, + en: 1, + ar: 2, + es: 3, + hebrew: 0, + english: 1, + arabic: 2, + spanish: 3, +}; + export interface SumitDiagnostic { hasData: boolean; dataKeys: string[]; @@ -164,27 +213,31 @@ export interface CreateDocumentSendByEmail { export interface BuildCreateDocumentPayloadParams { companyId: number; apiKey: string; - // SUMIT document type code. Use SUMIT_DOCUMENT_TYPE.TransactionInvoice (1) + // SUMIT document type code. Use `SUMIT_DOCUMENT_TYPE.ProformaInvoice` (3) // for חשבון עסקה. Other codes from SUMIT's enum can be passed as plain numbers. documentType: number; customer: CreateDocumentCustomer; items: CreateDocumentItem[]; - // Defaults to "ILS". Accepts the same shape as the charge endpoints but - // SUMIT documents take the literal string code, not the numeric one. + // Accepts the same shape as the charge endpoints. SUMIT's documents endpoint + // accepts the literal string code (`"ILS"` / `"USD"` / `"EUR"`). currency?: SumitCurrency; // Whether the UnitPrice values already include VAT. Defaults to true to // match SUMIT's API default; pass false if your items are net. vatIncluded?: boolean; vatPerItem?: boolean; vatRate?: number; - language?: string; + // Language. SUMIT expects a numeric code (`SUMIT_LANGUAGE.*`). For ergonomics + // this field also accepts the strings `"he"`/`"en"`/`"ar"`/`"es"` (and their + // full English names) and converts them. Unknown strings are dropped. + language?: number | "he" | "en" | "ar" | "es" | string; description?: string; externalReference?: string; date?: string; dueDate?: string; sendByEmail?: CreateDocumentSendByEmail; isDraft?: boolean; - responseLanguage?: string; + // Language for SUMIT's response messages. Same numeric enum as `language`. + responseLanguage?: number | "he" | "en" | "ar" | "es" | string; } type SumitCustomerSearchMode = 0 | 1 | 2; @@ -211,7 +264,7 @@ export interface SumitCreateDocumentPayload { Original: boolean; SendAsPaymentRequest: boolean; }; - Language?: string; + Language?: number; Currency?: "ILS" | "USD" | "EUR" | string; Description?: string; ExternalReference?: string; @@ -232,11 +285,11 @@ export interface SumitCreateDocumentPayload { SearchMode: SumitCustomerSearchMode; }; }>; - Payments: never[]; + Payments?: never[]; VATIncluded: boolean; VATPerItem?: boolean; VATRate?: number; - ResponseLanguage?: string; + ResponseLanguage?: number; } type UnknownRecord = Record; @@ -305,17 +358,24 @@ export function buildCreateDocumentPayload(params: BuildCreateDocumentPayloadPar throw new Error("buildCreateDocumentPayload: items[] must not be empty"); } + // Derive SearchMode: if the caller didn't pick one, use 1 (find by SUMIT ID) + // when an `id` is supplied, 2 (upsert by ExternalIdentifier) when one is + // supplied, and 0 (create new) otherwise. Matches SUMIT's documented modes. + const derivedSearchMode: SumitCustomerSearchMode = + params.customer.searchMode ?? + (params.customer.id ? 1 : params.customer.externalIdentifier ? 2 : 0); + const customer = compact({ - SearchMode: (params.customer.searchMode ?? 0) as SumitCustomerSearchMode, - Name: params.customer.name, - EmailAddress: params.customer.emailAddress, - Phone: params.customer.phone, - ExternalIdentifier: params.customer.externalIdentifier, - ID: params.customer.id, - CompanyNumber: params.customer.taxId, - Address: params.customer.address, - City: params.customer.city, - ZipCode: params.customer.zipCode, + SearchMode: derivedSearchMode, + Name: blankToUndefined(params.customer.name) ?? params.customer.name, + EmailAddress: blankToUndefined(params.customer.emailAddress), + Phone: blankToUndefined(params.customer.phone), + ExternalIdentifier: blankToUndefined(params.customer.externalIdentifier), + ID: blankToUndefined(params.customer.id), + CompanyNumber: blankToUndefined(params.customer.taxId), + Address: blankToUndefined(params.customer.address), + City: blankToUndefined(params.customer.city), + ZipCode: blankToUndefined(params.customer.zipCode), NoVAT: params.customer.noVAT, }) as SumitCreateDocumentPayload["Details"]["Customer"]; @@ -331,12 +391,12 @@ export function buildCreateDocumentPayload(params: BuildCreateDocumentPayloadPar Type: params.documentType, Customer: customer, SendByEmail: sendByEmail, - Language: params.language, + Language: toLanguageCode(params.language), Currency: params.currency ? currencyToSumitString(params.currency) : undefined, - Description: params.description, - ExternalReference: params.externalReference, - Date: params.date, - DueDate: params.dueDate, + Description: blankToUndefined(params.description), + ExternalReference: blankToUndefined(params.externalReference), + Date: blankToUndefined(params.date), + DueDate: blankToUndefined(params.dueDate), IsDraft: params.isDraft, }) as SumitCreateDocumentPayload["Details"]; @@ -350,9 +410,9 @@ export function buildCreateDocumentPayload(params: BuildCreateDocumentPayloadPar ...(item.vat !== undefined ? { VAT: item.vat } : {}), Item: compact({ Name: item.name, - Description: item.description, - SKU: item.sku, - ExternalIdentifier: item.externalIdentifier, + Description: blankToUndefined(item.description), + SKU: blankToUndefined(item.sku), + ExternalIdentifier: blankToUndefined(item.externalIdentifier), SearchMode: 0 as SumitCustomerSearchMode, }) as SumitCreateDocumentPayload["Items"][number]["Item"], }; @@ -362,14 +422,28 @@ export function buildCreateDocumentPayload(params: BuildCreateDocumentPayloadPar Credentials: { CompanyID: params.companyId, APIKey: params.apiKey }, Details: details, Items: items, - Payments: [] as never[], VATIncluded: params.vatIncluded ?? true, VATPerItem: params.vatPerItem, VATRate: params.vatRate, - ResponseLanguage: params.responseLanguage, + ResponseLanguage: toLanguageCode(params.responseLanguage), }) as SumitCreateDocumentPayload; } +function toLanguageCode(value: number | string | undefined): number | undefined { + if (typeof value === "number") return value; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const mapped = LANGUAGE_STRING_TO_CODE[trimmed.toLowerCase()]; + return mapped; +} + +function blankToUndefined(value: string | null | undefined): string | undefined { + if (value === null || value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} + export function normalizeCreateDocumentResponse(response: unknown): NormalizedSumitEvent { if (!isRecord(response)) { return unmappedDiagnostic(null);