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