diff --git a/packages/altoviz/behaviour.test.ts b/packages/altoviz/behaviour.test.ts new file mode 100644 index 000000000..58fd8cb1f --- /dev/null +++ b/packages/altoviz/behaviour.test.ts @@ -0,0 +1,433 @@ +/** + * Verifies the specific live-API behaviours this plugin was built around. + * Each test here corresponds to a finding in the PR body - if one of these + * goes red, a claim in that document is no longer true. + */ +import { + Account, + Colleagues, + CustomerFamilies, + Customers, + Products, + PurchaseInvoices, + SaleCredits, + SaleInvoices, +} from './endpoints'; +import { buildPagingQuery, parsePageInfo } from './endpoints/shared'; +import { + installFetchMock, + lastCall, + makeCtx, + makeDb, + queueResponse, + recordedCalls, + requestedBody, + resetFetchMock, +} from './test-utils'; + +const unit = { id: 1, code: 'H', name: 'Heures', type: 'Time' }; +const vat = { id: 2, rate: 20, region: 'FR', label: '20% - FR', default: true }; +const family = { id: 3, label: 'Family', number: 'CRF-001' }; + +function seededDb() { + const db = makeDb(); + db.units.upsertByEntityId(String(unit.id), unit); + db.vats.upsertByEntityId(String(vat.id), vat); + db.customerFamilies.upsertByEntityId(String(family.id), family); + return db; +} + +beforeEach(() => { + resetFetchMock(); + installFetchMock(); +}); + +describe('parsePageInfo: paging headers arrive case-insensitively', () => { + test('reads provider-cased header keys', () => { + const info = parsePageInfo({ + 'X-Page-Index': '2', + 'X-Record-Count': '250', + 'X-Page-Next': '/v1/Customers?PageIndex=3', + }); + expect(info.pageIndex).toBe(2); + expect(info.recordCount).toBe(250); + expect(info.hasNext).toBe(true); + expect(info.hasPrevious).toBe(false); + }); +}); + +describe('read-modify-write: PUT clears every field the caller omits', () => { + test('customers.update sends every other field back unchanged, not just the one supplied', async () => { + const { ctx } = makeCtx(seededDb()); + const current = { + id: 1, + type: 'Business', + companyName: 'Old Name', + firstName: 'Ada', + lastName: 'Testcase', + email: 'ada@example.com', + phone: '+33100000000', + cellPhone: '+33600000000', + title: 'Mr', + number: 'C-1', + internalId: 'ext-1', + active: true, + internalNotes: 'note', + billingAddress: { city: 'Paris', zipcode: '75001', countryIso: 'FR' }, + shippingAddress: null, + billingOptions: { allowed: true }, + companyInformations: { siret: null }, + family: null, + }; + queueResponse(current); // the GET + queueResponse({ ...current, companyName: 'New Name' }); // the PUT response + + await Customers.update(ctx, { customerId: 1, companyName: 'New Name' }); + + const calls = recordedCalls(); + expect(calls).toHaveLength(2); + const putBody = requestedBody(calls[1]) as Record; + + // the one field supplied went through + expect(putBody.companyName).toBe('New Name'); + // every other field the caller did not touch must still be present - + // this is the entire fix for the PUT-clears-fields finding + expect(putBody.email).toBe('ada@example.com'); + expect(putBody.phone).toBe('+33100000000'); + expect(putBody.firstName).toBe('Ada'); + expect(putBody.lastName).toBe('Testcase'); + expect(putBody.internalNotes).toBe('note'); + expect(putBody.billingAddress).toMatchObject({ + city: 'Paris', + zipCode: '75001', + countryCode: 'FR', + }); + }); + + test('suppliers.update and colleagues.update also read-modify-write', async () => { + const { ctx: supplierCtx } = makeCtx(); + queueResponse({ + id: 1, + name: 'Old', + email: 'a@example.com', + phone: '+331', + }); + queueResponse({ id: 1, name: 'New' }); + const { Suppliers } = await import('./endpoints'); + await Suppliers.update(supplierCtx, { supplierId: 1, name: 'New' }); + const supplierPut = requestedBody(recordedCalls()[1]) as Record< + string, + unknown + >; + expect(supplierPut.email).toBe('a@example.com'); + expect(supplierPut.phone).toBe('+331'); + + resetFetchMock(); + installFetchMock(); + const { ctx: colleagueCtx } = makeCtx(); + queueResponse({ + id: 1, + firstName: 'Colin', + lastName: 'Old', + email: 'c@example.com', + }); + queueResponse({ id: 1, lastName: 'New' }); + await Colleagues.update(colleagueCtx, { colleagueId: 1, lastName: 'New' }); + const colleaguePut = requestedBody(recordedCalls()[1]) as Record< + string, + unknown + >; + expect(colleaguePut.firstName).toBe('Colin'); + expect(colleaguePut.email).toBe('c@example.com'); + }); +}); + +describe('nested references are resolved to their value form, never sent as a bare id', () => { + test('products.create resolves unitId/vatId/familyId from the mirror to {code}/{rate,region}/{label,number}', async () => { + const db = seededDb(); + db.productFamilies.upsertByEntityId('4', { + id: 4, + label: 'Product Family', + number: 'PRF-001', + }); + const { ctx } = makeCtx(db); + queueResponse({ id: 10, name: 'P' }); + + await Products.create(ctx, { + name: 'P', + type: 'Service', + unitId: unit.id, + vatId: vat.id, + familyId: 4, + }); + + const body = requestedBody() as Record; + expect(body.unit).toEqual({ code: 'H' }); + expect(body.vat).toEqual({ rate: 20, region: 'FR' }); + expect(body.family).toEqual({ label: 'Product Family', number: 'PRF-001' }); + // the raw ids must never reach the wire in this shape + expect(body).not.toHaveProperty('unitId'); + expect(body).not.toHaveProperty('vatId'); + expect(body).not.toHaveProperty('familyId'); + }); + + test('a cache miss falls back to a live list call rather than silently omitting the reference', async () => { + const { ctx } = makeCtx(makeDb()); // empty mirror + queueResponse([unit]); // GET_UNITS fallback + queueResponse({ id: 10, name: 'P' }); // the create + + await Products.create(ctx, { name: 'P', type: 'Service', unitId: unit.id }); + + const calls = recordedCalls(); + expect(calls[0]?.url).toContain('v1/units'); + const body = requestedBody(calls[1]) as Record; + expect(body.unit).toEqual({ code: 'H' }); + }); + + test('an id with no match anywhere throws rather than silently dropping the reference', async () => { + const { ctx } = makeCtx(makeDb()); + queueResponse([]); // live list comes back empty too + + await expect( + Products.create(ctx, { name: 'P', type: 'Service', unitId: 999 }), + ).rejects.toThrow(/999/); + }); + + test('customers.create resolves familyId the same way', async () => { + const { ctx } = makeCtx(seededDb()); + queueResponse({ id: 1, family }); + + await Customers.create(ctx, { + type: 'Business', + companyName: 'Acme', + familyId: family.id, + }); + + const body = requestedBody() as Record; + expect(body.family).toEqual({ label: 'Family', number: 'CRF-001' }); + }); + + test('family resolve walks past page 1', async () => { + const { ctx } = makeCtx(makeDb()); + const page1 = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + label: 'L', + number: `N${i}`, + })); + queueResponse(page1); + queueResponse([{ id: 101, label: 'Target', number: 'T-101' }]); + queueResponse({ id: 1 }); + + await Customers.create(ctx, { + type: 'Business', + companyName: 'Acme', + familyId: 101, + }); + + expect(recordedCalls()[0]?.url).toContain('PageIndex=1'); + expect(recordedCalls()[1]?.url).toContain('PageIndex=2'); + expect(requestedBody(recordedCalls()[2])).toMatchObject({ + family: { label: 'Target', number: 'T-101' }, + }); + }); +}); + +describe('sale document lines: unitPrice is rejected client-side, taxExcludedPrice is the real field', () => { + test('a line schema rejects an unrecognised key (unitPrice) before the request is built', async () => { + const { AltovizLineInputSchema } = await import('./endpoints/shared'); + const result = AltovizLineInputSchema.safeParse({ + type: 'Service', + quantity: 1, + unitPrice: 999, // deliberately not part of the schema - .strict() must reject it + }); + expect(result.success).toBe(false); + }); + + test('saleInvoices.create sends taxExcludedPrice on the wire, never unitPrice', async () => { + const { ctx } = makeCtx(seededDb()); + queueResponse({ id: 1 }); + + await SaleInvoices.create(ctx, { + customerId: 1, + date: '2026-01-01', + lines: [ + { + type: 'Service', + description: 'x', + quantity: 1, + taxExcludedPrice: 100, + unitId: unit.id, + vatId: vat.id, + }, + ], + }); + + const body = requestedBody() as { lines: Array> }; + expect(body.lines[0]?.taxExcludedPrice).toBe(100); + expect(body.lines[0]).not.toHaveProperty('unitPrice'); + }); +}); + +describe('contact eviction on parent delete', () => { + test("customers.delete fetches the customer's contacts and evicts each from the mirror", async () => { + const db = seededDb(); + db.contacts.upsertByEntityId('50', { id: 50, displayName: 'Auto Contact' }); + const { ctx } = makeCtx(db); + + queueResponse([{ id: 50, displayName: 'Auto Contact', isMain: true }]); // GET_CUSTOMER_CONTACTS + queueResponse({}); // DELETE + + await Customers.delete(ctx, { customerId: 1 }); + + expect(recordedCalls()[0]?.url).toContain('/contacts'); + expect(recordedCalls()[1]?.init.method).toBe('DELETE'); + expect(db.contacts.deleteByEntityId).toHaveBeenCalledWith('50'); + }); + + test('a failed contact lookup does not fail the parent delete', async () => { + const { ctx } = makeCtx(seededDb()); + let calls = 0; + global.fetch = (async (url: string, init: RequestInit) => { + calls++; + if (calls === 1) throw new Error('network down'); + return { + ok: true, + status: 200, + statusText: 'OK', + url, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => ({}), + text: async () => '{}', + } as unknown as Response; + }) as unknown as typeof global.fetch; + + await expect(Customers.delete(ctx, { customerId: 1 })).resolves.toEqual({ + deleted: true, + id: 1, + }); + expect(calls).toBe(2); + installFetchMock(); + }); +}); + +describe('pagination', () => { + test('pageIndex defaults to 1, never 0', () => { + const query = buildPagingQuery({}); + expect(query.PageIndex).toBe(1); + }); + + test('an explicit pageIndex is honoured and omitted fields are dropped, not sent as undefined', () => { + const query = buildPagingQuery({ pageIndex: 2, pageSize: 50 }); + expect(query).toEqual({ PageIndex: 2, PageSize: 50 }); + expect(query).not.toHaveProperty('OrderBy'); + expect(query).not.toHaveProperty('query'); + }); +}); + +describe('sale credit update resends lines in full', () => { + test('omitting lines is not possible - the schema requires at least one', async () => { + const { AltovizEndpointInputSchemas } = await import('./endpoints/types'); + const result = AltovizEndpointInputSchemas.saleCreditsUpdate.safeParse({ + creditId: 1, + }); + expect(result.success).toBe(false); + }); + + test('update reads the current credit and keeps create-managed fields', async () => { + const { ctx } = makeCtx(seededDb()); + queueResponse({ + id: 1, + customerId: 9, + cancelledInvoicetId: 40, + cancelledInvoicetNumber: 'F-40', + date: '2026-01-01', + globalDiscount: { type: 'Percent', value: 10 }, + vatMode: 'Auto', + region: 'FR', + internalId: 'keep-me', + metadata: { a: 1 }, + isDraft: true, + }); + queueResponse({ id: 1 }); + + await SaleCredits.update(ctx, { + creditId: 1, + subject: 'updated', + lines: [ + { + type: 'Service', + description: 'x', + quantity: 1, + taxExcludedPrice: 10, + unitId: unit.id, + vatId: vat.id, + }, + ], + }); + + const put = requestedBody(recordedCalls()[1]) as Record; + expect(put.subject).toBe('updated'); + expect(put.customerId).toBe(9); + expect(put.cancelledInvoicetId).toBe(40); + expect(put.globalDiscount).toEqual({ type: 'Percent', value: 10 }); + expect(put.internalId).toBe('keep-me'); + expect(put.region).toBe('FR'); + }); +}); + +describe('purchase invoice upload', () => { + test('rejects malformed Base64 before opening a request', async () => { + const { ctx } = makeCtx(); + await expect( + PurchaseInvoices.upload(ctx, { + fileBase64: '%%%not-base64%%%', + fileName: 'x.pdf', + mimeType: 'application/pdf', + }), + ).rejects.toThrow(/Base64/); + expect(recordedCalls()).toHaveLength(0); + }); + + test('sends fileName on the multipart File', async () => { + const { ctx } = makeCtx(); + queueResponse({ id: 1 }); + await PurchaseInvoices.upload(ctx, { + fileBase64: Buffer.from('hi').toString('base64'), + fileName: 'invoice-42.pdf', + mimeType: 'application/pdf', + }); + const body = lastCall().init.body; + expect(body).toBeInstanceOf(FormData); + const file = (body as FormData).get('file'); + expect(file).toBeInstanceOf(File); + expect((file as File).name).toBe('invoice-42.pdf'); + }); +}); + +describe('GET retries in the client (bind discards successful retries)', () => { + test('a 429 GET that succeeds on retry returns the body', async () => { + const { ctx } = makeCtx(); + queueResponse('', { + status: 429, + contentType: null, + headers: { 'retry-after': '1' }, + }); + queueResponse([{ id: 1, code: 'H', name: 'Heures', type: 'Time' }]); + const units = await Account.getUnits(ctx, {}); + expect(units).toEqual([{ id: 1, code: 'H', name: 'Heures', type: 'Time' }]); + expect(recordedCalls()).toHaveLength(2); + }); + + test('a 429 POST is not retried', async () => { + const { ctx } = makeCtx(); + queueResponse('', { + status: 429, + contentType: null, + headers: { 'retry-after': '1' }, + }); + await expect( + CustomerFamilies.create(ctx, { label: 'F' }), + ).rejects.toThrow(); + expect(recordedCalls()).toHaveLength(1); + }); +}); diff --git a/packages/altoviz/client.ts b/packages/altoviz/client.ts new file mode 100644 index 000000000..fada26452 --- /dev/null +++ b/packages/altoviz/client.ts @@ -0,0 +1,144 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +const ALTOVIZ_API_BASE = 'https://api.altoviz.com'; + +/** + * Measured live: quota is 100 requests over a rolling window. The 429 carries + * `Retry-After` in milliseconds (13_000 / 36_000), not HTTP-spec seconds. + * corsair/http multiplies that value by 1000, so transport-level retries would + * sleep for hours and would also replay POSTs. maxRetries is 0 here. + * + * GET retries happen in `makeAltovizRequest` instead of corsair's bind layer: + * bind awaits a successful retry then still throws the original error + * (`packages/corsair/core/endpoints/bind.ts`). This plugin PR cannot change + * that file. + */ +const ALTOVIZ_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'retry-after', + }, +}; + +const GET_RETRY_LIMIT = 3; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getRetryDelayMs(error: unknown): number | undefined { + if (error instanceof ApiError && error.status === 429) { + return error.retryAfter != null ? error.retryAfter / 1000 : 1000; + } + return undefined; +} + +export class AltovizAPIError extends Error { + public readonly status?: number; + public readonly body?: unknown; + + constructor( + message: string, + options?: { cause?: Error; status?: number; body?: unknown }, + ) { + super(message, options?.cause ? { cause: options.cause } : undefined); + this.name = 'AltovizAPIError'; + this.status = options?.status; + this.body = options?.body; + } +} + +/** + * Path ids go through `options.path` and a constant `{id}` template, never + * concatenated into the URL string. That keeps caller values off the + * `{(.*?)}` placeholder regex in `corsair/http` (CodeQL js/polynomial-redos). + */ +export type AltovizRequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: Record | unknown[]; + query?: Record; + path?: Record; + /** + * For the one multipart operation in the surface (purchase invoice upload). + * A plain record — the shared transport builds the actual `FormData` and + * accepts string or Blob values per field. + */ + formData?: Record; +}; + +/** + * Issues an Altoviz request with the X-API-KEY header, this plugin's rate-limit + * retry policy, and error handlers. + * + * Three routes (the PDF downloads) answer with `application/pdf`, which the + * shared transport's `getResponseBody` decodes with `response.text()` — lossless + * for the JSON/text paths every other operation here uses, lossy for those + * three. That is a `corsair/async-core` limitation flagged in the PR rather + * than fixed here (see `packages/googledrive`'s `filesDownload` for the same + * caveat on another plugin), so `download` responses in this plugin type their + * body as an opaque string and document that it may not be byte-exact. + */ +export async function makeAltovizRequest( + url: string, + apiKey: string, + options: AltovizRequestOptions = {}, +): Promise { + const { method = 'GET', body, query, formData, path } = options; + + const config: OpenAPIConfig = { + BASE: ALTOVIZ_API_BASE, + VERSION: '1', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + ENCODE_PATH: encodeURIComponent, + HEADERS: { + 'X-API-KEY': apiKey, + ...(formData ? {} : { 'Content-Type': 'application/json' }), + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: url.startsWith('/') ? url : `/${url}`, + path, + body: formData ? undefined : body, + formData, + mediaType: formData ? undefined : 'application/json; charset=utf-8', + query, + }; + + const retrySafe = method === 'GET'; + let lastError: unknown; + for (let attempt = 1; attempt <= GET_RETRY_LIMIT + 1; attempt++) { + try { + return await request(config, requestOptions, { + rateLimitConfig: ALTOVIZ_RATE_LIMIT_CONFIG, + }); + } catch (error) { + lastError = error; + const delay = retrySafe ? getRetryDelayMs(error) : undefined; + if (delay == null || attempt > GET_RETRY_LIMIT) break; + await sleep(delay); + } + } + + if (lastError instanceof Error) { + const status = (lastError as { status?: number }).status; + const body = (lastError as { body?: unknown }).body; + throw new AltovizAPIError(lastError.message, { + cause: lastError, + status, + body, + }); + } + throw new AltovizAPIError('Unknown error'); +} diff --git a/packages/altoviz/endpoints.test.ts b/packages/altoviz/endpoints.test.ts new file mode 100644 index 000000000..06f1b5747 --- /dev/null +++ b/packages/altoviz/endpoints.test.ts @@ -0,0 +1,223 @@ +/** + * Registry invariants: risk levels agree with the endpoint tree, the + * non-idempotent set is exactly the non-read operations (nothing more, + * nothing less), the UNREGISTER_WEBHOOK guard rejects a call with neither id + * nor url, and the audit-payload allow-list cannot admit anything that looks + * like personal or financial content. + */ + +import { ALLOWED_FIELDS, auditPayload } from './endpoints/logging'; +import { AltovizEndpointInputSchemas } from './endpoints/types'; +import { isNonIdempotent, NON_IDEMPOTENT_OPERATIONS } from './error-handlers'; +import { altovizEndpointMeta, altovizEndpointsNested } from './index'; + +function registeredPaths(): string[] { + const paths: string[] = []; + for (const [group, ops] of Object.entries(altovizEndpointsNested)) { + for (const op of Object.keys(ops as Record)) { + paths.push(`${group}.${op}`); + } + } + return paths; +} + +describe('registry invariants', () => { + test('every registered operation has metadata', () => { + const paths = registeredPaths(); + expect(paths.length).toBe(67); + for (const path of paths) { + // toHaveProperty splits on '.' by default; these keys ARE dotted literals. + expect(altovizEndpointMeta).toHaveProperty([path]); + } + }); + + test('risk levels are only read, write or destructive, matching totals: 41 read, 15 write, 11 destructive', () => { + const counts = { read: 0, write: 0, destructive: 0 }; + for (const meta of Object.values(altovizEndpointMeta)) { + expect(['read', 'write', 'destructive']).toContain(meta.riskLevel); + counts[meta.riskLevel as keyof typeof counts]++; + } + expect(counts).toEqual({ read: 41, write: 15, destructive: 11 }); + }); + + test('every destructive operation is marked irreversible', () => { + const entries = Object.entries(altovizEndpointMeta) as Array< + [string, { riskLevel: string; irreversible?: boolean }] + >; + const destructive = entries.filter( + ([, m]) => m.riskLevel === 'destructive', + ); + expect(destructive.length).toBe(11); + for (const [, meta] of destructive) { + expect(meta.irreversible).toBe(true); + } + }); +}); + +describe('the non-idempotent set is exactly the non-read operations', () => { + test('coverage sweep: the set is non-empty and matches the registry size class', () => { + expect(NON_IDEMPOTENT_OPERATIONS.size).toBe(26); + }); + + test('every non-idempotent path is registered and is not a read', () => { + const meta = altovizEndpointMeta as Record; + for (const path of NON_IDEMPOTENT_OPERATIONS) { + expect(meta).toHaveProperty([path]); + expect(meta[path]?.riskLevel).not.toBe('read'); + } + }); + + test('every non-read operation is in the non-idempotent set - nothing slips through silently', () => { + const nonRead = Object.entries(altovizEndpointMeta) + .filter(([, m]) => m.riskLevel !== 'read') + .map(([path]) => path); + expect(nonRead.length).toBe(26); + expect([...nonRead].sort()).toEqual([...NON_IDEMPOTENT_OPERATIONS].sort()); + }); + + test('isNonIdempotent agrees with the set', () => { + expect(isNonIdempotent('customers.create')).toBe(true); + expect(isNonIdempotent('customers.get')).toBe(false); + expect(isNonIdempotent('not.a.real.operation')).toBe(false); + }); +}); + +describe('the UNREGISTER_WEBHOOK guard', () => { + const schema = AltovizEndpointInputSchemas.webhookSubscriptionsUnregister; + + test('rejects a call with neither id nor url', () => { + expect(schema.safeParse({}).success).toBe(false); + }); + + test('accepts a call with only webhookId', () => { + expect(schema.safeParse({ webhookId: 1 }).success).toBe(true); + }); + + test('accepts a call with only url', () => { + expect(schema.safeParse({ url: 'https://example.com/wh' }).success).toBe( + true, + ); + }); + + test('rejects a call with both id and url', () => { + expect( + schema.safeParse({ webhookId: 1, url: 'https://example.com/wh' }).success, + ).toBe(false); + }); +}); + +describe('documented input constraints', () => { + const listSchema = AltovizEndpointInputSchemas.customersList; + const createInvoiceSchema = AltovizEndpointInputSchemas.saleInvoicesCreate; + + test('accepts page sizes from 1 through 100 only', () => { + expect(listSchema.safeParse({ pageSize: 1 }).success).toBe(true); + expect(listSchema.safeParse({ pageSize: 100 }).success).toBe(true); + expect(listSchema.safeParse({ pageSize: 0 }).success).toBe(false); + expect(listSchema.safeParse({ pageSize: 101 }).success).toBe(false); + }); + + test('requires YYYY-MM-DD calendar dates before making a request', () => { + const base = { customerId: 1, lines: [{ description: 'Service' }] }; + expect( + createInvoiceSchema.safeParse({ ...base, date: '2026-08-15' }).success, + ).toBe(true); + expect( + createInvoiceSchema.safeParse({ ...base, date: '15/08/2026' }).success, + ).toBe(false); + }); + + test('products.find rejects an empty call the API would 400', () => { + const schema = AltovizEndpointInputSchemas.productsFind; + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ number: 'ABC' }).success).toBe(true); + }); +}); + +describe('audit payload: deny-by-default allow-list', () => { + test('the allow-list admits no field name that looks like personal or financial content', () => { + const forbidden = [ + 'email', + 'phone', + 'address', + 'name', + 'note', + 'description', + 'subject', + 'amount', + 'price', + 'quantity', + 'iban', + 'siret', + 'secret', + 'signature', + ]; + const hits: string[] = []; + for (const field of ALLOWED_FIELDS) { + const lower = field.toLowerCase(); + for (const stem of forbidden) { + if (lower.includes(stem)) hits.push(`${field}~${stem}`); + } + } + expect(hits).toEqual([]); + }); + + test('an allowed field is recorded by value', () => { + const payload = auditPayload({ customerId: 42, companyName: 'Acme Corp' }); + expect(payload.customerId).toBe(42); + }); + + test('a not-allowed field is recorded by name only, never by value', () => { + const payload = auditPayload({ + customerId: 42, + companyName: 'Acme Corp', + email: 'a@example.com', + }); + expect(payload).not.toHaveProperty('companyName'); + expect(payload).not.toHaveProperty('email'); + expect(payload.fields).toEqual( + expect.arrayContaining(['companyName', 'email', 'customerId']), + ); + }); + + test('free-text search query is recorded by name only, never by value', () => { + const payload = auditPayload({ pageIndex: 1, query: 'jane@example.com' }); + expect(payload).not.toHaveProperty('query'); + expect(payload.pageIndex).toBe(1); + expect(payload.fields).toEqual(expect.arrayContaining(['query'])); + }); + + test('orderBy is recorded by name only, never by value', () => { + const payload = auditPayload({ + pageIndex: 1, + orderBy: 'email,iban,siret', + }); + expect(payload).not.toHaveProperty('orderBy'); + expect(payload.pageIndex).toBe(1); + expect(payload.fields).toEqual(expect.arrayContaining(['orderBy'])); + }); + + test('caller-chosen identifiers are recorded by name only, never by value', () => { + const payload = auditPayload({ + customerId: 42, + internalId: 'ssn-shaped', + number: 'FR-iban-lookalike', + }); + expect(payload.customerId).toBe(42); + expect(payload).not.toHaveProperty('internalId'); + expect(payload).not.toHaveProperty('number'); + expect(payload.fields).toEqual( + expect.arrayContaining(['internalId', 'number']), + ); + }); + + test('undefined fields are not recorded at all, not even by name', () => { + const payload = auditPayload({ customerId: 42, email: undefined }); + expect(payload.fields).not.toContain('email'); + }); + + test('extra fields passed by the endpoint author bypass the allow-list (they are not raw caller input)', () => { + const payload = auditPayload({}, { linesCount: 3 }); + expect(payload.linesCount).toBe(3); + }); +}); diff --git a/packages/altoviz/endpoints/account.ts b/packages/altoviz/endpoints/account.ts new file mode 100644 index 000000000..f9ecaacfd --- /dev/null +++ b/packages/altoviz/endpoints/account.ts @@ -0,0 +1,99 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheClassification, cacheUnit, cacheVat } from './persist'; +import type { AltovizEndpointOutputs } from './types'; + +export const getCurrentUser: AltovizEndpoints['account']['getCurrentUser'] = + async (ctx) => { + // /v1/users/me and /v1/users/whoami share an operationId and return a + // byte-identical body, confirmed live - only one route is called here. + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountGetCurrentUser'] + >('v1/users/me', ctx.key); + + await logEventFromContext( + ctx, + 'altoviz.account.getCurrentUser', + {}, + 'completed', + ); + return result; + }; + +/** + * `/hello` takes no parameters. The spec declares an optional `api-version` + * query parameter, but sending it - with the document's own version string - + * returns 400 with an empty body, confirmed live. Sending nothing returns 200 + * with the account identity, so nothing is sent. + */ +export const testApiKey: AltovizEndpoints['account']['testApiKey'] = async ( + ctx, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountTestApiKey'] + >('hello', ctx.key); + + await logEventFromContext(ctx, 'altoviz.account.testApiKey', {}, 'completed'); + return result; +}; + +export const getSettings: AltovizEndpoints['account']['getSettings'] = async ( + ctx, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountGetSettings'] + >('v1/settings', ctx.key); + + await logEventFromContext( + ctx, + 'altoviz.account.getSettings', + {}, + 'completed', + ); + return result; +}; + +export const getUnits: AltovizEndpoints['account']['getUnits'] = async ( + ctx, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountGetUnits'] + >('v1/units', ctx.key); + + for (const unit of result) await cacheUnit(ctx.db.units, unit); + + await logEventFromContext(ctx, 'altoviz.account.getUnits', {}, 'completed'); + return result; +}; + +export const getVats: AltovizEndpoints['account']['getVats'] = async (ctx) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountGetVats'] + >('v1/vats', ctx.key); + + for (const vat of result) await cacheVat(ctx.db.vats, vat); + + await logEventFromContext(ctx, 'altoviz.account.getVats', {}, 'completed'); + return result; +}; + +export const getClassifications: AltovizEndpoints['account']['getClassifications'] = + async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['accountGetClassifications'] + >('v1/classifications', ctx.key, { query: { type: input.type } }); + + for (const classification of result) { + await cacheClassification(ctx.db.classifications, classification); + } + + await logEventFromContext( + ctx, + 'altoviz.account.getClassifications', + auditPayload(input), + 'completed', + ); + return result; + }; diff --git a/packages/altoviz/endpoints/colleagues.ts b/packages/altoviz/endpoints/colleagues.ts new file mode 100644 index 000000000..50c971ce9 --- /dev/null +++ b/packages/altoviz/endpoints/colleagues.ts @@ -0,0 +1,119 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs, ColleagueOutput } from './types'; + +/** No CREATE_COLLEAGUE in the 67-op catalog - get/update/delete reference an id this plugin cannot itself produce. */ +export const get: AltovizEndpoints['colleagues']['get'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['colleaguesGet'] + >(`v1/colleagues/{id}`, ctx.key, { path: { id: input.colleagueId } }); + + await logEventFromContext( + ctx, + 'altoviz.colleagues.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const list: AltovizEndpoints['colleagues']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['colleaguesList'] + >('v1/colleagues', ctx.key, { query: buildPagingQuery(input) }); + + await logEventFromContext( + ctx, + 'altoviz.colleagues.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** + * A PARTIAL body here is a 500, not a 400 - confirmed live, distinct from the + * clearing-PUT behaviour elsewhere. Read-modify-write happens to fix both + * problems at once, since the full merged record is always sent. + */ +export const update: AltovizEndpoints['colleagues']['update'] = async ( + ctx, + input, +) => { + const current = await makeAltovizRequest( + 'v1/colleagues/{id}', + ctx.key, + { path: { id: input.colleagueId } }, + ); + + const body = compactBody({ + id: input.colleagueId, + firstName: input.firstName ?? current.firstName, + lastName: input.lastName ?? current.lastName, + name: input.name ?? current.name, + email: input.email ?? current.email, + phone: input.phone ?? current.phone, + cellPhone: input.cellPhone ?? current.cellPhone, + title: input.title ?? current.title, + number: input.number ?? current.number, + internalId: input.internalId ?? current.internalId, + isPartner: input.isPartner ?? current.isPartner, + initialPartnerBalance: + input.initialPartnerBalance ?? current.initialPartnerBalance, + homecareServiceNumber: + input.homecareServiceNumber ?? current.homecareServiceNumber, + userId: input.userId ?? current.userId, + // metadatas, not metadata - the plural is the provider spelling. + metadatas: input.metadatas ?? current.metadatas, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['colleaguesUpdate'] + >('v1/colleagues/{id}', ctx.key, { + method: 'PUT', + body, + path: { id: input.colleagueId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.colleagues.update', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** + * Colleague creation also auto-creates a contact (same behaviour as customers + * and suppliers), but there is no `colleagues.getContacts` route in the + * catalog to find it with, so - unlike `customers.delete` and + * `suppliers.delete` - this cannot evict it from the mirror. The orphan is a + * known, documented gap rather than a silent one. + */ +export const remove: AltovizEndpoints['colleagues']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/colleagues/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.colleagueId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.colleagues.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.colleagueId }; +}; diff --git a/packages/altoviz/endpoints/contacts.ts b/packages/altoviz/endpoints/contacts.ts new file mode 100644 index 000000000..13e6fcfdc --- /dev/null +++ b/packages/altoviz/endpoints/contacts.ts @@ -0,0 +1,100 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheContact } from './persist'; +import { buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +/** No customerId field on this route, and none is accepted - confirmed live. There is no way to attach a standalone contact to a customer through this operation. */ +export const create: AltovizEndpoints['contacts']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + phone: input.phone, + cellPhone: input.cellPhone, + companyName: input.companyName, + function: input.function, + service: input.service, + title: input.title, + displayName: input.displayName, + invertedDisplayName: input.invertedDisplayName, + internalId: input.internalId, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['contactsCreate'] + >('v1/contacts', ctx.key, { method: 'POST', body }); + + await cacheContact(ctx.db.contacts, result); + + await logEventFromContext( + ctx, + 'altoviz.contacts.create', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['contacts']['get'] = async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['contactsGet'] + >(`v1/contacts/{id}`, ctx.key, { path: { id: input.contactId } }); + + await cacheContact(ctx.db.contacts, result); + + await logEventFromContext( + ctx, + 'altoviz.contacts.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Returns an array, same as FIND_CUSTOMER - not a single object, and not null when nothing matches. */ +export const find: AltovizEndpoints['contacts']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['contactsFind'] + >('v1/contacts/find', ctx.key, { + query: { email: input.email, internalId: input.internalId }, + }); + + for (const contact of result) await cacheContact(ctx.db.contacts, contact); + + await logEventFromContext( + ctx, + 'altoviz.contacts.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Also returns the shadow contacts auto-created by customer, supplier and colleague writes - confirmed live. */ +export const list: AltovizEndpoints['contacts']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['contactsList'] + >('v1/contacts', ctx.key, { query: buildPagingQuery(input) }); + + for (const contact of result) await cacheContact(ctx.db.contacts, contact); + + await logEventFromContext( + ctx, + 'altoviz.contacts.list', + auditPayload(input), + 'completed', + ); + return result; +}; diff --git a/packages/altoviz/endpoints/customer-families.ts b/packages/altoviz/endpoints/customer-families.ts new file mode 100644 index 000000000..712e1b512 --- /dev/null +++ b/packages/altoviz/endpoints/customer-families.ts @@ -0,0 +1,92 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheCustomerFamily, evictEntity } from './persist'; +import { buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +export const create: AltovizEndpoints['customerFamilies']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ + label: input.label, + number: input.number, + internalId: input.internalId, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customerFamiliesCreate'] + >('v1/customerfamilies', ctx.key, { method: 'POST', body }); + + await cacheCustomerFamily(ctx.db.customerFamilies, result); + + await logEventFromContext( + ctx, + 'altoviz.customerFamilies.create', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['customerFamilies']['get'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customerFamiliesGet'] + >(`v1/customerfamilies/{id}`, ctx.key, { path: { id: input.familyId } }); + + await cacheCustomerFamily(ctx.db.customerFamilies, result); + + await logEventFromContext( + ctx, + 'altoviz.customerFamilies.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** No cascade: a family that still holds a member answers 409, not a delete - confirmed live. The 409 is reported by CONFLICT_ERROR in error-handlers.ts, not swallowed here. */ +export const remove: AltovizEndpoints['customerFamilies']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/customerfamilies/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.familyId }, + }); + + await evictEntity(ctx.db.customerFamilies, input.familyId, 'customer family'); + + await logEventFromContext( + ctx, + 'altoviz.customerFamilies.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.familyId }; +}; + +export const list: AltovizEndpoints['customerFamilies']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customerFamiliesList'] + >('v1/customerfamilies', ctx.key, { query: buildPagingQuery(input) }); + + for (const family of result) + await cacheCustomerFamily(ctx.db.customerFamilies, family); + + await logEventFromContext( + ctx, + 'altoviz.customerFamilies.list', + auditPayload(input), + 'completed', + ); + return result; +}; diff --git a/packages/altoviz/endpoints/customers.ts b/packages/altoviz/endpoints/customers.ts new file mode 100644 index 000000000..2f77c8b9a --- /dev/null +++ b/packages/altoviz/endpoints/customers.ts @@ -0,0 +1,282 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { + cacheContact, + cacheCustomer, + evictContacts, + evictEntity, + fetchContactsForParent, +} from './persist'; +import { + addressOutputToInput, + buildPagingQuery, + compactBody, + resolveCustomerFamilyRef, +} from './shared'; +import type { + AltovizEndpointOutputs, + ContactOutput, + CustomerOutput, +} from './types'; + +export const create: AltovizEndpoints['customers']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ + type: input.type, + companyName: input.companyName, + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + phone: input.phone, + cellPhone: input.cellPhone, + title: input.title, + number: input.number, + internalId: input.internalId, + active: input.active, + billingAddress: input.billingAddress, + shippingAddress: input.shippingAddress, + billingOptions: input.billingOptions, + companyInformations: input.companyInformations, + // { id } is silently dropped by the API - resolve to {label, number} first. + family: await resolveCustomerFamilyRef( + ctx.db.customerFamilies, + ctx.key, + input.familyId, + ), + internalNotes: input.internalNotes, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersCreate'] + >('v1/customers', ctx.key, { method: 'POST', body }); + + await cacheCustomer(ctx.db.customers, result); + + await logEventFromContext( + ctx, + 'altoviz.customers.create', + auditPayload(input, { hasFamily: input.familyId !== undefined }), + 'completed', + ); + return result; +}; + +/** + * PUT clears every field the body omits - confirmed live. This reads the + * current record first and merges the caller's fields over the FULL set of + * writable fields before sending the PUT, so a caller supplying one field + * never loses the rest. + */ +export const update: AltovizEndpoints['customers']['update'] = async ( + ctx, + input, +) => { + const current = await makeAltovizRequest( + 'v1/customers/{id}', + ctx.key, + { path: { id: input.customerId } }, + ); + + const family = + input.familyId !== undefined + ? await resolveCustomerFamilyRef( + ctx.db.customerFamilies, + ctx.key, + input.familyId, + ) + : current.family && current.family.label && current.family.number + ? { label: current.family.label, number: current.family.number } + : undefined; + + const body = compactBody({ + id: input.customerId, + type: input.type ?? current.type, + companyName: input.companyName ?? current.companyName, + firstName: input.firstName ?? current.firstName, + lastName: input.lastName ?? current.lastName, + email: input.email ?? current.email, + phone: input.phone ?? current.phone, + cellPhone: input.cellPhone ?? current.cellPhone, + title: input.title ?? current.title, + number: input.number ?? current.number, + internalId: input.internalId ?? current.internalId, + active: input.active ?? current.active, + internalNotes: input.internalNotes ?? current.internalNotes, + billingAddress: + input.billingAddress ?? addressOutputToInput(current.billingAddress), + shippingAddress: + input.shippingAddress ?? addressOutputToInput(current.shippingAddress), + billingOptions: input.billingOptions ?? current.billingOptions, + companyInformations: + input.companyInformations ?? current.companyInformations, + family, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersUpdate'] + >('v1/customers/{id}', ctx.key, { + method: 'PUT', + body, + path: { id: input.customerId }, + }); + + await cacheCustomer(ctx.db.customers, result); + + await logEventFromContext( + ctx, + 'altoviz.customers.update', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** + * Deleting a customer does not delete the contact its own creation + * auto-generated - confirmed live. The contacts are fetched before the parent + * delete so the eviction has something to evict; this is best-effort and must + * not block or fail the delete itself. + */ +export const remove: AltovizEndpoints['customers']['delete'] = async ( + ctx, + input, +) => { + const contacts = await fetchContactsForParent( + () => + makeAltovizRequest( + 'v1/customers/{id}/contacts', + ctx.key, + { path: { id: input.customerId } }, + ), + `customer ${input.customerId}`, + ); + + await makeAltovizRequest('v1/customers/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.customerId }, + }); + + await evictContacts( + ctx.db.contacts, + contacts, + `customer ${input.customerId}`, + ); + + await evictEntity(ctx.db.customers, input.customerId, 'customer'); + + await logEventFromContext( + ctx, + 'altoviz.customers.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.customerId }; +}; + +export const get: AltovizEndpoints['customers']['get'] = async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersGet'] + >(`v1/customers/{id}`, ctx.key, { path: { id: input.customerId } }); + + await cacheCustomer(ctx.db.customers, result); + + await logEventFromContext( + ctx, + 'altoviz.customers.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** internalId is a caller-supplied string; encoding is ENCODE_PATH in the client, not interpolation. */ +export const getByInternalId: AltovizEndpoints['customers']['getByInternalId'] = + async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersGetByInternalId'] + >('v1/customers/getbyinternalid/{internalId}', ctx.key, { + path: { internalId: input.internalId }, + }); + + await cacheCustomer(ctx.db.customers, result); + + await logEventFromContext( + ctx, + 'altoviz.customers.getByInternalId', + auditPayload(input), + 'completed', + ); + return result; + }; + +/** Returns an array, not a single object or null - confirmed live, even with no parameters at all (200 []). */ +export const find: AltovizEndpoints['customers']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersFind'] + >('v1/customers/find', ctx.key, { + query: { + email: input.email, + internalId: input.internalId, + number: input.number, + }, + }); + + for (const customer of result) + await cacheCustomer(ctx.db.customers, customer); + + await logEventFromContext( + ctx, + 'altoviz.customers.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const list: AltovizEndpoints['customers']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersList'] + >('v1/customers', ctx.key, { query: buildPagingQuery(input) }); + + for (const customer of result) + await cacheCustomer(ctx.db.customers, customer); + + await logEventFromContext( + ctx, + 'altoviz.customers.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const getContacts: AltovizEndpoints['customers']['getContacts'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['customersGetContacts'] + >(`v1/customers/{id}/contacts`, ctx.key, { + path: { id: input.customerId }, + }); + + for (const contact of result) await cacheContact(ctx.db.contacts, contact); + + await logEventFromContext( + ctx, + 'altoviz.customers.getContacts', + auditPayload(input), + 'completed', + ); + return result; +}; diff --git a/packages/altoviz/endpoints/index.ts b/packages/altoviz/endpoints/index.ts new file mode 100644 index 000000000..103d75383 --- /dev/null +++ b/packages/altoviz/endpoints/index.ts @@ -0,0 +1,220 @@ +import { + getClassifications, + getCurrentUser, + getSettings, + getUnits, + getVats, + testApiKey, +} from './account'; +import { + get as colleaguesGet, + list as colleaguesList, + remove as colleaguesRemove, + update as colleaguesUpdate, +} from './colleagues'; +import { + create as contactsCreate, + find as contactsFind, + get as contactsGet, + list as contactsList, +} from './contacts'; +import { + create as customerFamiliesCreate, + get as customerFamiliesGet, + list as customerFamiliesList, + remove as customerFamiliesRemove, +} from './customer-families'; +import { + create as customersCreate, + find as customersFind, + get as customersGet, + getByInternalId as customersGetByInternalId, + getContacts as customersGetContacts, + list as customersList, + remove as customersRemove, + update as customersUpdate, +} from './customers'; +import { + create as productFamiliesCreate, + get as productFamiliesGet, + list as productFamiliesList, + remove as productFamiliesRemove, +} from './product-families'; +import { + create as productsCreate, + find as productsFind, + findByNumberOrId as productsFindByNumberOrId, + get as productsGet, + remove as productsRemove, +} from './products'; +import { + download as purchaseInvoicesDownload, + upload as purchaseInvoicesUpload, +} from './purchase-invoices'; +import { + create as receiptsCreate, + find as receiptsFind, + get as receiptsGet, + list as receiptsList, + remove as receiptsRemove, + update as receiptsUpdate, +} from './receipts'; +import { + create as saleCreditsCreate, + download as saleCreditsDownload, + find as saleCreditsFind, + get as saleCreditsGet, + list as saleCreditsList, + remove as saleCreditsRemove, + update as saleCreditsUpdate, +} from './sale-credits'; +import { + create as saleInvoicesCreate, + download as saleInvoicesDownload, + find as saleInvoicesFind, + get as saleInvoicesGet, + list as saleInvoicesList, + remove as saleInvoicesRemove, +} from './sale-invoices'; +import { + find as saleQuotesFind, + list as saleQuotesList, + remove as saleQuotesRemove, +} from './sale-quotes'; +import { + get as suppliersGet, + getContacts as suppliersGetContacts, + list as suppliersList, + remove as suppliersRemove, + update as suppliersUpdate, +} from './suppliers'; +import { + list as webhookSubscriptionsList, + register as webhookSubscriptionsRegister, + unregister as webhookSubscriptionsUnregister, +} from './webhook-subscriptions'; + +/** Customer CRUD and lookup helpers; update preserves omitted fields with read-modify-write. */ +export const Customers = { + create: customersCreate, + update: customersUpdate, + delete: customersRemove, + get: customersGet, + getByInternalId: customersGetByInternalId, + find: customersFind, + list: customersList, + getContacts: customersGetContacts, +}; + +/** Customer-family create/get/delete/list; delete is refused while customers reference it. */ +export const CustomerFamilies = { + create: customerFamiliesCreate, + get: customerFamiliesGet, + delete: customerFamiliesRemove, + list: customerFamiliesList, +}; + +/** Supplier reads and safe updates; contacts are returned through the supplier relationship. */ +export const Suppliers = { + get: suppliersGet, + list: suppliersList, + update: suppliersUpdate, + delete: suppliersRemove, + getContacts: suppliersGetContacts, +}; + +/** Standalone contact creation and lookup, independent of customer or supplier lists. */ +export const Contacts = { + create: contactsCreate, + get: contactsGet, + find: contactsFind, + list: contactsList, +}; + +/** Colleague reads and safe updates; omitted update fields retain their current values. */ +export const Colleagues = { + get: colleaguesGet, + list: colleaguesList, + update: colleaguesUpdate, + delete: colleaguesRemove, +}; + +/** API-key validation plus current-user, settings, units, VAT, and classification references. */ +export const Account = { + getCurrentUser, + testApiKey, + getSettings, + getUnits, + getVats, + getClassifications, +}; + +/** Webhook registration and removal; unregister accepts exactly one webhook ID or callback URL. */ +export const WebhookSubscriptions = { + list: webhookSubscriptionsList, + register: webhookSubscriptionsRegister, + unregister: webhookSubscriptionsUnregister, +}; + +/** Product creation, deletion, and lookups; unit, VAT, and family IDs are resolved for writes. */ +export const Products = { + create: productsCreate, + delete: productsRemove, + get: productsGet, + find: productsFind, + findByNumberOrId: productsFindByNumberOrId, +}; + +/** Product-family create/get/delete operations and paginated listing. */ +export const ProductFamilies = { + create: productFamiliesCreate, + get: productFamiliesGet, + delete: productFamiliesRemove, + list: productFamiliesList, +}; + +/** Invoice lifecycle helpers; create requires lines and delete applies to drafts only. */ +export const SaleInvoices = { + create: saleInvoicesCreate, + get: saleInvoicesGet, + find: saleInvoicesFind, + list: saleInvoicesList, + delete: saleInvoicesRemove, + download: saleInvoicesDownload, +}; + +/** Credit lifecycle helpers; updates resend the complete line collection. */ +export const SaleCredits = { + create: saleCreditsCreate, + update: saleCreditsUpdate, + get: saleCreditsGet, + find: saleCreditsFind, + list: saleCreditsList, + delete: saleCreditsRemove, + download: saleCreditsDownload, +}; + +/** This catalog exposes quote lookup/list/delete; create/send/download remain outside its scope. */ +export const SaleQuotes = { + find: saleQuotesFind, + list: saleQuotesList, + delete: saleQuotesRemove, +}; + +/** Receipt lifecycle helpers; linking a receipt requires a finalized sale document. */ +export const Receipts = { + create: receiptsCreate, + update: receiptsUpdate, + get: receiptsGet, + find: receiptsFind, + list: receiptsList, + delete: receiptsRemove, +}; + +/** Base64 PDF upload and download; uploaded purchase invoices are deleted in the Altoviz UI. */ +export const PurchaseInvoices = { + upload: purchaseInvoicesUpload, + download: purchaseInvoicesDownload, +}; + +export * from './types'; diff --git a/packages/altoviz/endpoints/logging.ts b/packages/altoviz/endpoints/logging.ts new file mode 100644 index 000000000..b72535828 --- /dev/null +++ b/packages/altoviz/endpoints/logging.ts @@ -0,0 +1,116 @@ +/** + * Builds the payload recorded in `corsair_events`. + * + * This is an accounting system: customer and contact names, emails, phones, + * billing and shipping addresses, company registration numbers, invoice line + * prices, receipt amounts and payment references pass through nearly every + * write here. `logEventFromContext` persists whatever it is handed and those + * rows inherit the event log's retention, so spreading a raw endpoint input + * would park that data in the log indefinitely. + * + * The allow-list below is therefore DENY BY DEFAULT: a field's VALUE is + * recorded only if its name is explicitly admitted, and admission is reserved + * for identifiers, enum values and counts - things that identify a record or + * shape a query, never things that identify a person or carry money. Every + * other supplied field has its NAME recorded (so an operator can see what a + * call touched) but never its value. + * + * A parameter added to an operation in the future is redacted by default, + * before anyone reviews it - the failure mode an allow-first design cannot + * produce is "forgot to add the new field to the deny-list". + */ +const ALLOWED_FIELDS = new Set([ + // ids and cross-references - identify a record, do not describe a person + 'id', + 'customerId', + 'customerId2', + 'supplierId', + 'colleagueId', + 'contactId', + 'productId', + 'familyId', + 'customerFamilyId', + 'productFamilyId', + 'invoiceId', + 'creditId', + 'quoteId', + 'receiptId', + 'webhookId', + 'purchaseInvoiceId', + 'unitId', + 'vatId', + 'classificationId', + 'cancelledInvoiceId', + // enums and status - describe shape/state, not content + 'type', + 'status', + 'paymentMethod', + 'riskLevel', + // paging and query shape - `query` and `orderBy` are free-text, so they + // are recorded by NAME only, never by value. + 'pageIndex', + 'pageSize', + 'from', + 'to', + // booleans - describe shape, never carry content + 'active', + 'isDraft', + 'isPaid', + 'includeCancelled', + // counts + 'linesCount', + 'typesCount', + // dates - when, not who or how much + 'date', +]); + +/** Guards the allow-list itself: no admitted field name may look like it carries personal or financial content. */ +const FORBIDDEN_STEMS = [ + 'email', + 'phone', + 'address', + 'name', + 'note', + 'description', + 'subject', + 'amount', + 'price', + 'quantity', + 'iban', + 'siret', + 'vatnumber', + 'secret', + 'signature', + 'key', +]; +for (const field of ALLOWED_FIELDS) { + const lower = field.toLowerCase(); + if (FORBIDDEN_STEMS.some((stem) => lower.includes(stem))) { + throw new Error( + `[ALTOVIZ] logging allow-list admits "${field}", which looks like it could carry personal or financial content`, + ); + } +} + +export function auditPayload>( + input: T, + extra?: Record, +): Record { + const payload: Record = { ...extra }; + + for (const [key, value] of Object.entries(input)) { + if (value === undefined) continue; + if (ALLOWED_FIELDS.has(key)) { + payload[key] = value; + } + } + + const supplied = Object.keys(input).filter((key) => input[key] !== undefined); + if (supplied.length > 0) { + payload.fields = supplied; + } + + return payload; +} + +export { ALLOWED_FIELDS }; diff --git a/packages/altoviz/endpoints/persist.ts b/packages/altoviz/endpoints/persist.ts new file mode 100644 index 000000000..954176bc5 --- /dev/null +++ b/packages/altoviz/endpoints/persist.ts @@ -0,0 +1,178 @@ +import type { ZodType } from 'zod'; +import { + AltovizClassificationEntity, + AltovizContactEntity, + AltovizCustomerEntity, + AltovizCustomerFamilyEntity, + AltovizProductEntity, + AltovizProductFamilyEntity, + AltovizUnitEntity, + AltovizVatEntity, +} from '../schema/database'; + +/** + * Minimal structural view of a Corsair entity store - only the operations + * these helpers need, so they stay usable whatever else the concrete store + * exposes. + */ +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; + deleteByEntityId?: (entityId: string) => Promise; +}; + +/** Caching is best-effort: a plugin call must not fail because the local mirror could not be written. */ +function describeError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return 'unknown'; +} + +async function safely( + operation: () => Promise, + action: 'cache' | 'evict', + what: string, +) { + try { + await operation(); + } catch (error) { + console.warn( + `[ALTOVIZ] failed to ${action} ${what}: ${describeError(error)}`, + ); + } +} + +async function cacheParsed( + store: EntityStore | undefined, + schema: ZodType, + row: unknown, + what: string, +) { + if (!store || row == null) return; + const parsed = schema.safeParse(row); + if (!parsed.success) return; + await safely( + () => store.upsertByEntityId(String(parsed.data.id), parsed.data), + 'cache', + `${what} ${parsed.data.id}`, + ); +} + +export async function cacheUnit( + store: EntityStore | undefined, + unit: unknown, +) { + await cacheParsed(store, AltovizUnitEntity, unit, 'unit'); +} + +export async function cacheVat( + store: EntityStore | undefined, + vat: unknown, +) { + await cacheParsed(store, AltovizVatEntity, vat, 'vat'); +} + +export async function cacheClassification( + store: EntityStore | undefined, + classification: unknown, +) { + await cacheParsed( + store, + AltovizClassificationEntity, + classification, + 'classification', + ); +} + +export async function cacheCustomerFamily( + store: EntityStore | undefined, + family: unknown, +) { + await cacheParsed( + store, + AltovizCustomerFamilyEntity, + family, + 'customer family', + ); +} + +export async function cacheProductFamily( + store: EntityStore | undefined, + family: unknown, +) { + await cacheParsed( + store, + AltovizProductFamilyEntity, + family, + 'product family', + ); +} + +export async function cacheProduct( + store: EntityStore | undefined, + product: unknown, +) { + await cacheParsed(store, AltovizProductEntity, product, 'product'); +} + +export async function cacheCustomer( + store: EntityStore | undefined, + customer: unknown, +) { + await cacheParsed(store, AltovizCustomerEntity, customer, 'customer'); +} + +export async function cacheContact( + store: EntityStore | undefined, + contact: unknown, +) { + await cacheParsed(store, AltovizContactEntity, contact, 'contact'); +} + +/** + * Drops a cached record after the provider confirmed the delete. Takes only + * the delete half of the store signature so the parameter stays covariant + * across the different concrete per-entity clients. + */ +type DeletableStore = { + deleteByEntityId?: (entityId: string) => Promise; +}; + +export async function evictEntity( + store: DeletableStore | undefined, + id: number, + what: string, +) { + const remove = store?.deleteByEntityId; + if (!remove) return; + await safely(() => remove(String(id)), 'evict', `${what} ${id}`); +} + +/** + * Creating a customer or supplier auto-creates a contact from its name fields + * (confirmed live: `GET .../contacts` returns it with `isMain: true`), and + * deleting the parent does NOT delete that contact. Fetch the list while the + * parent still exists, then evict after the delete succeeds. Best-effort: a + * failed lookup must not block the parent delete. + */ +export async function fetchContactsForParent( + fetchContacts: () => Promise>, + what: string, +): Promise> { + try { + return await fetchContacts(); + } catch (error) { + console.warn( + `[ALTOVIZ] failed to list contacts for ${what}: ${describeError(error)}`, + ); + return []; + } +} + +export async function evictContacts( + store: DeletableStore | undefined, + contacts: Array<{ id: number }>, + what: string, +) { + for (const contact of contacts) { + await evictEntity(store, contact.id, `${what} contact`); + } +} diff --git a/packages/altoviz/endpoints/product-families.ts b/packages/altoviz/endpoints/product-families.ts new file mode 100644 index 000000000..d062a9821 --- /dev/null +++ b/packages/altoviz/endpoints/product-families.ts @@ -0,0 +1,89 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheProductFamily, evictEntity } from './persist'; +import { buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +/** No internalId here, unlike customer families. */ +export const create: AltovizEndpoints['productFamilies']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ label: input.label, number: input.number }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productFamiliesCreate'] + >('v1/productfamilies', ctx.key, { method: 'POST', body }); + + await cacheProductFamily(ctx.db.productFamilies, result); + + await logEventFromContext( + ctx, + 'altoviz.productFamilies.create', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['productFamilies']['get'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productFamiliesGet'] + >(`v1/productfamilies/{id}`, ctx.key, { path: { id: input.familyId } }); + + await cacheProductFamily(ctx.db.productFamilies, result); + + await logEventFromContext( + ctx, + 'altoviz.productFamilies.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Same 409-not-cascade rule as customer families - confirmed live for both. */ +export const remove: AltovizEndpoints['productFamilies']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/productfamilies/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.familyId }, + }); + + await evictEntity(ctx.db.productFamilies, input.familyId, 'product family'); + + await logEventFromContext( + ctx, + 'altoviz.productFamilies.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.familyId }; +}; + +export const list: AltovizEndpoints['productFamilies']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productFamiliesList'] + >('v1/productfamilies', ctx.key, { query: buildPagingQuery(input) }); + + for (const family of result) + await cacheProductFamily(ctx.db.productFamilies, family); + + await logEventFromContext( + ctx, + 'altoviz.productFamilies.list', + auditPayload(input), + 'completed', + ); + return result; +}; diff --git a/packages/altoviz/endpoints/products.ts b/packages/altoviz/endpoints/products.ts new file mode 100644 index 000000000..5ebdcd408 --- /dev/null +++ b/packages/altoviz/endpoints/products.ts @@ -0,0 +1,130 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheProduct, evictEntity } from './persist'; +import { + compactBody, + resolveProductFamilyRef, + resolveUnitRef, + resolveVatRef, +} from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +export const create: AltovizEndpoints['products']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ + name: input.name, + number: input.number, + description: input.description, + type: input.type, + unitPrice: input.unitPrice, + purchasePrice: input.purchasePrice, + isUnitPriceTaxIncluded: input.isUnitPriceTaxIncluded, + defaultQuantity: input.defaultQuantity, + // { id } is silently ignored for unit/family and a 400 for vat - + // resolve every nested reference to its value form first. + unit: await resolveUnitRef(ctx.db.units, ctx.key, input.unitId), + vat: await resolveVatRef(ctx.db.vats, ctx.key, input.vatId), + family: await resolveProductFamilyRef( + ctx.db.productFamilies, + ctx.key, + input.familyId, + ), + internalId: input.internalId, + internalNotes: input.internalNotes, + active: input.active, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productsCreate'] + >('v1/products', ctx.key, { method: 'POST', body }); + + await cacheProduct(ctx.db.products, result); + + await logEventFromContext( + ctx, + 'altoviz.products.create', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const remove: AltovizEndpoints['products']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest(`v1/products/{id}`, ctx.key, { + method: 'DELETE', + path: { id: input.productId }, + }); + + await evictEntity(ctx.db.products, input.productId, 'product'); + + await logEventFromContext( + ctx, + 'altoviz.products.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.productId }; +}; + +export const get: AltovizEndpoints['products']['get'] = async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productsGet'] + >(`v1/products/{id}`, ctx.key, { path: { id: input.productId } }); + + await cacheProduct(ctx.db.products, result); + + await logEventFromContext( + ctx, + 'altoviz.products.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Same route as FIND_PRODUCT_BY_NUMBER_OR_ID (`GET /v1/products/find`) - two catalog rows, one endpoint. Returns an array. */ +export const find: AltovizEndpoints['products']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productsFind'] + >('v1/products/find', ctx.key, { query: { number: input.number } }); + + for (const product of result) await cacheProduct(ctx.db.products, product); + + await logEventFromContext( + ctx, + 'altoviz.products.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Superset of FIND_PRODUCT. With neither parameter the API 400s "Number or internal ID have to be defined" - enforced client-side by the input schema first. */ +export const findByNumberOrId: AltovizEndpoints['products']['findByNumberOrId'] = + async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['productsFindByNumberOrId'] + >('v1/products/find', ctx.key, { + query: { number: input.number, internalId: input.internalId }, + }); + + for (const product of result) await cacheProduct(ctx.db.products, product); + + await logEventFromContext( + ctx, + 'altoviz.products.findByNumberOrId', + auditPayload(input), + 'completed', + ); + return result; + }; diff --git a/packages/altoviz/endpoints/purchase-invoices.ts b/packages/altoviz/endpoints/purchase-invoices.ts new file mode 100644 index 000000000..a95bf1012 --- /dev/null +++ b/packages/altoviz/endpoints/purchase-invoices.ts @@ -0,0 +1,78 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import type { AltovizEndpointOutputs } from './types'; + +const MAX_PURCHASE_INVOICE_BYTES = 10 * 1024 * 1024; + +function decodeFileBase64(raw: string): Buffer { + const compact = raw.replace(/\s/g, ''); + const pad = (4 - (compact.length % 4)) % 4; + const normalized = compact + '='.repeat(pad); + const bytes = Buffer.from(normalized, 'base64'); + if (bytes.toString('base64') !== normalized) { + throw new Error('fileBase64 is not valid Base64'); + } + if (bytes.length > MAX_PURCHASE_INVOICE_BYTES) { + throw new Error( + `Purchase invoice exceeds ${MAX_PURCHASE_INVOICE_BYTES} bytes`, + ); + } + return bytes; +} + +/** + * The only multipart operation in the surface, and the only create with NO + * delete anywhere in the API - not in the catalog and not in the OpenAPI + * document. An uploaded document can only be removed in the Altoviz UI. + * `fileBase64` is decoded to a `File` here because JSON-RPC-style plugin + * inputs cannot carry a raw binary value; the shared transport's `formData` + * option accepts string or Blob field values and builds the actual + * `multipart/form-data` body. `File` (not `Blob`) keeps `fileName` on + * Content-Disposition. + */ +export const upload: AltovizEndpoints['purchaseInvoices']['upload'] = async ( + ctx, + input, +) => { + const bytes = decodeFileBase64(input.fileBase64); + const file = new File([bytes], input.fileName, { type: input.mimeType }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['purchaseInvoicesUpload'] + >('v1/purchaseinvoices/file', ctx.key, { + method: 'POST', + formData: { file }, + }); + + await logEventFromContext( + ctx, + 'altoviz.purchaseInvoices.upload', + auditPayload({}, { fileSizeBytes: bytes.length }), + 'completed', + ); + return result; +}; + +/** + * Returns `application/pdf` despite the spec declaring `application/json` on + * this route's 200 - confirmed live, and it round-tripped an uploaded file + * exactly. Same core text-decoding limitation as the sale document downloads. + */ +export const download: AltovizEndpoints['purchaseInvoices']['download'] = + async (ctx, input) => { + const body = await makeAltovizRequest( + 'v1/purchaseinvoices/download/{id}', + ctx.key, + { path: { id: input.purchaseInvoiceId } }, + ); + + await logEventFromContext( + ctx, + 'altoviz.purchaseInvoices.download', + auditPayload(input), + 'completed', + ); + return { contentType: 'application/pdf', body }; + }; diff --git a/packages/altoviz/endpoints/receipts.ts b/packages/altoviz/endpoints/receipts.ts new file mode 100644 index 000000000..4caaf54d0 --- /dev/null +++ b/packages/altoviz/endpoints/receipts.ts @@ -0,0 +1,159 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs, ReceiptOutput } from './types'; + +/** + * `links` attaches this receipt to a Commitment | Invoice | Credit, but + * linking to a DRAFT document is refused live ("Impossible d'encaisser un + * document en brouillon ... vous devez le finaliser au prealable"). Finalize + * is out of scope for this plugin, so `links` is largely unreachable through + * catalog operations alone - the receipt still creates fine standalone, which + * is the shape this handler is built and tested against. + */ +export const create: AltovizEndpoints['receipts']['create'] = async ( + ctx, + input, +) => { + const body = compactBody({ + amount: input.amount, + date: input.date, + paymentMethod: input.paymentMethod, + status: input.status, + reference: input.reference, + notes: input.notes, + customerId: input.customerId, + customerName: input.customerName, + customerNumber: input.customerNumber, + customerInternalId: input.customerInternalId, + links: input.links, + internalId: input.internalId, + metadata: input.metadata, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['receiptsCreate'] + >('v1/receipts', ctx.key, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.create', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** customerId (or number / internalId) is required even on update - confirmed live: omitting all three is "Customer ID, number or internal ID must be defined". Read-modify-write, same as every other update in this plugin. */ +export const update: AltovizEndpoints['receipts']['update'] = async ( + ctx, + input, +) => { + const current = await makeAltovizRequest( + 'v1/receipts/{id}', + ctx.key, + { path: { id: input.receiptId } }, + ); + + const body = compactBody({ + id: input.receiptId, + amount: input.amount ?? current.amount, + date: input.date ?? current.date, + paymentMethod: input.paymentMethod ?? current.paymentMethod, + status: input.status ?? current.status, + reference: input.reference ?? current.reference, + notes: input.notes ?? current.notes, + customerId: input.customerId ?? current.customerId, + customerName: current.customerName, + customerNumber: current.customerNumber, + customerInternalId: current.customerInternalId, + links: input.links ?? current.links, + internalId: current.internalId, + metadata: current.metadata, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['receiptsUpdate'] + >('v1/receipts/{id}', ctx.key, { + method: 'PUT', + body, + path: { id: input.receiptId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.update', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['receipts']['get'] = async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['receiptsGet'] + >(`v1/receipts/{id}`, ctx.key, { path: { id: input.receiptId } }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** The catalog calls this "by customer internal ID"; the live parameter is the receipt's own internalId. */ +export const find: AltovizEndpoints['receipts']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['receiptsFind'] + >('v1/receipts/find', ctx.key, { query: { internalId: input.internalId } }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const list: AltovizEndpoints['receipts']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['receiptsList'] + >('v1/receipts', ctx.key, { query: buildPagingQuery(input) }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const remove: AltovizEndpoints['receipts']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/receipts/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.receiptId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.receipts.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.receiptId }; +}; diff --git a/packages/altoviz/endpoints/sale-credits.ts b/packages/altoviz/endpoints/sale-credits.ts new file mode 100644 index 000000000..54ad2ceb6 --- /dev/null +++ b/packages/altoviz/endpoints/sale-credits.ts @@ -0,0 +1,210 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildLine, buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs, SaleCreditOutput } from './types'; + +export const create: AltovizEndpoints['saleCredits']['create'] = async ( + ctx, + input, +) => { + const lists = new Map(); + const lines = await Promise.all( + input.lines.map((line) => + buildLine( + line, + { units: ctx.db.units, vats: ctx.db.vats }, + ctx.key, + lists, + ), + ), + ); + + const body = compactBody({ + customerId: input.customerId, + // The provider's own spelling, typo included - do not "fix" it. + cancelledInvoicetId: input.cancelledInvoicetId, + cancelledInvoicetNumber: input.cancelledInvoicetNumber, + date: input.date, + subject: input.subject, + headerNotes: input.headerNotes, + footerNotes: input.footerNotes, + lines, + globalDiscount: input.globalDiscount, + vatMode: input.vatMode, + region: input.region, + isDraft: input.isDraft, + internalId: input.internalId, + metadata: input.metadata, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleCreditsCreate'] + >('v1/salecredits', ctx.key, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.create', + auditPayload(input, { linesCount: input.lines.length }), + 'completed', + ); + return result; +}; + +/** Drafts only. Lines must be resent in full - confirmed live, omitting them empties the credit, the same clearing-write behaviour PUT has everywhere else in this API. */ +export const update: AltovizEndpoints['saleCredits']['update'] = async ( + ctx, + input, +) => { + const current = await makeAltovizRequest( + 'v1/salecredits/{id}', + ctx.key, + { path: { id: input.creditId } }, + ); + + const lists = new Map(); + const lines = await Promise.all( + input.lines.map((line) => + buildLine( + line, + { units: ctx.db.units, vats: ctx.db.vats }, + ctx.key, + lists, + ), + ), + ); + + const body = compactBody({ + id: input.creditId, + customerId: input.customerId ?? current.customerId, + cancelledInvoicetId: current.cancelledInvoicetId, + cancelledInvoicetNumber: current.cancelledInvoicetNumber, + date: input.date ?? current.date, + subject: input.subject ?? current.subject, + headerNotes: input.headerNotes ?? current.headerNotes, + footerNotes: input.footerNotes ?? current.footerNotes, + lines, + globalDiscount: current.globalDiscount, + vatMode: current.vatMode, + region: current.region, + isDraft: input.isDraft ?? current.isDraft, + internalId: current.internalId, + metadata: current.metadata, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleCreditsUpdate'] + >('v1/salecredits/{id}', ctx.key, { + method: 'PUT', + body, + path: { id: input.creditId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.update', + auditPayload(input, { linesCount: input.lines.length }), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['saleCredits']['get'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleCreditsGet'] + >(`v1/salecredits/{id}`, ctx.key, { path: { id: input.creditId } }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const find: AltovizEndpoints['saleCredits']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleCreditsFind'] + >('v1/salecredits/find', ctx.key, { + query: { internalId: input.internalId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** No Status filter here, unlike invoices. */ +export const list: AltovizEndpoints['saleCredits']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleCreditsList'] + >('v1/salecredits', ctx.key, { + query: { + ...buildPagingQuery(input), + From: input.from, + To: input.to, + CustomerId: input.customerId, + }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const remove: AltovizEndpoints['saleCredits']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/salecredits/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.creditId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.creditId }; +}; + +/** Real application/pdf, confirmed live (81 KB) - same core text-decoding limitation as the invoice download. */ +export const download: AltovizEndpoints['saleCredits']['download'] = async ( + ctx, + input, +) => { + const body = await makeAltovizRequest( + 'v1/salecredits/download/{id}', + ctx.key, + { path: { id: input.creditId } }, + ); + + await logEventFromContext( + ctx, + 'altoviz.saleCredits.download', + auditPayload(input), + 'completed', + ); + return { contentType: 'application/pdf', body }; +}; diff --git a/packages/altoviz/endpoints/sale-invoices.ts b/packages/altoviz/endpoints/sale-invoices.ts new file mode 100644 index 000000000..29616c022 --- /dev/null +++ b/packages/altoviz/endpoints/sale-invoices.ts @@ -0,0 +1,162 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildLine, buildPagingQuery, compactBody } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +export const create: AltovizEndpoints['saleInvoices']['create'] = async ( + ctx, + input, +) => { + const lists = new Map(); + const lines = await Promise.all( + input.lines.map((line) => + buildLine( + line, + { units: ctx.db.units, vats: ctx.db.vats }, + ctx.key, + lists, + ), + ), + ); + + const body = compactBody({ + customerId: input.customerId, + date: input.date, + subject: input.subject, + headerNotes: input.headerNotes, + footerNotes: input.footerNotes, + lines, + globalDiscount: input.globalDiscount, + shippingAmount: input.shippingAmount, + vatMode: input.vatMode, + region: input.region, + liableToVat: input.liableToVat, + vatReverseCharge: input.vatReverseCharge, + useTaxIncludedPrices: input.useTaxIncludedPrices, + isDraft: input.isDraft, + internalId: input.internalId, + metadata: input.metadata, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleInvoicesCreate'] + >('v1/saleinvoices', ctx.key, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.create', + auditPayload(input, { linesCount: input.lines.length }), + 'completed', + ); + return result; +}; + +export const get: AltovizEndpoints['saleInvoices']['get'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleInvoicesGet'] + >(`v1/saleinvoices/{id}`, ctx.key, { path: { id: input.invoiceId } }); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const find: AltovizEndpoints['saleInvoices']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleInvoicesFind'] + >('v1/saleinvoices/find', ctx.key, { + query: { internalId: input.internalId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const list: AltovizEndpoints['saleInvoices']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleInvoicesList'] + >('v1/saleinvoices', ctx.key, { + query: { + ...buildPagingQuery(input), + From: input.from, + To: input.to, + CustomerId: input.customerId, + Status: input.status, + IncludeCancelled: input.includeCancelled, + }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Drafts only - a finalized invoice is expected to refuse the delete; this plugin never finalizes anything, so that path was not exercised live. */ +export const remove: AltovizEndpoints['saleInvoices']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/saleinvoices/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.invoiceId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.invoiceId }; +}; + +/** + * Real `application/pdf`, confirmed live (82 KB, `content-disposition` naming + * the document number). The shared transport decodes non-JSON bodies with + * `response.text()`, which is lossless for the JSON/text paths every other + * operation in this plugin uses and lossy for this one - see the note on + * `makeAltovizRequest` in client.ts. Flagged as a core limitation in the PR + * rather than fixed here. + */ +export const download: AltovizEndpoints['saleInvoices']['download'] = async ( + ctx, + input, +) => { + const body = await makeAltovizRequest( + 'v1/saleinvoices/download/{id}', + ctx.key, + { path: { id: input.invoiceId } }, + ); + + await logEventFromContext( + ctx, + 'altoviz.saleInvoices.download', + auditPayload(input), + 'completed', + ); + return { contentType: 'application/pdf', body }; +}; diff --git a/packages/altoviz/endpoints/sale-quotes.ts b/packages/altoviz/endpoints/sale-quotes.ts new file mode 100644 index 000000000..1d1198051 --- /dev/null +++ b/packages/altoviz/endpoints/sale-quotes.ts @@ -0,0 +1,73 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { buildPagingQuery } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +/** Returns an array, empty when nothing matches - confirmed live against a real quote (created, found, listed, deleted end to end once quote numbering was initialised on the tenant). */ +export const find: AltovizEndpoints['saleQuotes']['find'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleQuotesFind'] + >('v1/salequotes/find', ctx.key, { query: { internalId: input.internalId } }); + + await logEventFromContext( + ctx, + 'altoviz.saleQuotes.find', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** + * No Status filter is exposed here. The spec emits `Status.From`, + * `Status.Status.From` and further nested variants - a generator artefact - + * and live, `Status` is silently ignored while `Status.Status` is a 500. A + * filter that does nothing is worse than no filter. + */ +export const list: AltovizEndpoints['saleQuotes']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['saleQuotesList'] + >('v1/salequotes', ctx.key, { + query: { + ...buildPagingQuery(input), + From: input.from, + To: input.to, + CustomerId: input.customerId, + }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleQuotes.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Deleting a quote that does not exist ALSO returns 200 - confirmed live - so this operation cannot report a miss to a caller. */ +export const remove: AltovizEndpoints['saleQuotes']['delete'] = async ( + ctx, + input, +) => { + await makeAltovizRequest('v1/salequotes/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.quoteId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.saleQuotes.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.quoteId }; +}; diff --git a/packages/altoviz/endpoints/shared.ts b/packages/altoviz/endpoints/shared.ts new file mode 100644 index 000000000..f9ffead14 --- /dev/null +++ b/packages/altoviz/endpoints/shared.ts @@ -0,0 +1,376 @@ +import { z } from 'zod'; +import { makeAltovizRequest } from '../client'; +import type { + AltovizCustomerFamilyEntity, + AltovizProductFamilyEntity, + AltovizUnitEntity, + AltovizVatEntity, +} from '../schema/database'; + +export const AltovizIdSchema = z.number().int(); + +/** Altoviz calendar dates use the documented `YYYY-MM-DD` wire format. */ +export const AltovizDateSchema = z.iso.date(); + +/** + * Every `{id}` path parameter in the Altoviz surface is int32 - confirmed live, + * a GUID or any other string is a 400 "The value ... is not valid.". + */ +export type AltovizId = z.infer; + +/** Strips `undefined` values so `compactBody` never sends a documented-default field the caller omitted. */ +export function compactBody( + body: Record, +): Record { + const compacted: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (value !== undefined) compacted[key] = value; + } + return compacted; +} + +/** Same as {@link compactBody}, for query strings. */ +export function compactQuery( + query: Record, +): Record { + const compacted: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) compacted[key] = value; + } + return compacted; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pagination +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `PageIndex` is 1-based - confirmed on all eleven list endpoints, + * `PageIndex=0` is a 400 "'Page Index' must be greater than or equal to '1'." + * Defaulting client-side to 1 is what keeps a caller that never thinks about + * paging from hitting that error on the very first call. + */ +export const PagingInputSchema = { + pageIndex: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(100).optional(), + /** Accepted by every list endpoint but silently ignored by the API - confirmed live. Exposed because it is documented, not because it works. */ + orderBy: z.string().optional(), + query: z.string().optional(), +}; + +export function buildPagingQuery(input: { + pageIndex?: number; + pageSize?: number; + orderBy?: string; + query?: string; +}): Record { + return compactQuery({ + PageIndex: input.pageIndex ?? 1, + PageSize: input.pageSize, + OrderBy: input.orderBy, + query: input.query, + }); +} + +/** + * Paging state arrives in response headers, not the body (which is a bare + * array) - `x-page-index`, `x-page-size`, `x-page-count`, `x-record-count`, + * plus `x-page-next` / `x-page-prev` when a further page exists. Altoviz's + * `x-page-next` value capitalises the path segment (`/v1/Customers?...`) + * differently from the lower-case route the client called, so this parses the + * query string out of it rather than trusting the path. + */ +export type AltovizPageInfo = { + pageIndex?: number; + pageSize?: number; + pageCount?: number; + recordCount?: number; + hasNext: boolean; + hasPrevious: boolean; +}; + +export function parsePageInfo( + headers: Record | undefined, +): AltovizPageInfo { + // Headers may arrive with provider casing (`X-Page-Index`), so fold keys to + // lower-case once - the field names below are already lower-case. + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers ?? {})) { + normalized[name.toLowerCase()] = value; + } + const get = (name: string) => normalized[name]; + const num = (name: string) => { + const raw = get(name); + if (raw === undefined) return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + }; + return { + pageIndex: num('x-page-index'), + pageSize: num('x-page-size'), + pageCount: num('x-page-count'), + recordCount: num('x-record-count'), + hasNext: get('x-page-next') !== undefined, + hasPrevious: get('x-page-prev') !== undefined, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Nested-reference resolution: THE central finding of this plugin +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `id` is `readOnly` on the Vat, Unit, CustomerFamily and ProductFamily + * schemas in Altoviz's own OpenAPI document, and the live behaviour is + * asymmetric in a way that makes it dangerous rather than merely + * inconvenient: + * + * vat: { id } -> 400 "La TVA n'existe pas." (loud) + * family: { id } -> 200, record comes back family: null (SILENT) + * + * Every write in this plugin that takes a unit, VAT or family therefore + * accepts the id an agent naturally has (from a prior read, or from the + * mirrored reference stores) and translates it into the value form the + * provider actually requires - `{code}` for units, `{rate, region}` for VAT, + * `{label, number}` for families - before the request is built. + * + * Resolution order: the local mirror first (cheap, and usually warm because + * these are reference tables read on the way to almost every write), then a + * live list call that also refreshes the mirror, and only then a thrown error + * naming the id - never a silent omission, which is the exact bug this + * function exists to prevent. + */ + +type EntityStore = { + findByEntityId: (entityId: string) => Promise<{ data: T } | null>; + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +/** Request-scoped list memo so parallel line resolves share one GET /units (or /vats). */ +export type RefListCache = Map>; + +const FAMILY_PAGE_SIZE = 100; +const FAMILY_MAX_PAGES = 20; + +async function fetchAllPages(url: string, key: string): Promise { + const all: T[] = []; + for (let page = 1; page <= FAMILY_MAX_PAGES; page++) { + const rows = await makeAltovizRequest(url, key, { + query: { PageIndex: page, PageSize: FAMILY_PAGE_SIZE }, + }); + all.push(...rows); + if (rows.length < FAMILY_PAGE_SIZE) break; + } + return all; +} + +async function resolveViaMirrorOrList(opts: { + store: EntityStore | undefined; + id: number; + fetchList: () => Promise; + kind: string; + lists?: RefListCache; + listKey: string; +}): Promise { + const cached = await opts.store?.findByEntityId(String(opts.id)); + if (cached) return cached.data; + + let pending = opts.lists?.get(opts.listKey) as Promise | undefined; + if (!pending) { + pending = opts.fetchList(); + opts.lists?.set(opts.listKey, pending as Promise); + } + const list = await pending; + + if (opts.store) { + for (const row of list) { + await opts.store.upsertByEntityId(String(row.id), row); + } + } + + const match = list.find((row) => row.id === opts.id); + if (!match) { + throw new Error( + `Altoviz ${opts.kind} ${opts.id} was not found in the local mirror or the live list - ` + + 'it may not exist, or the mirror needs the corresponding list endpoint called first.', + ); + } + return match; +} + +export async function resolveUnitRef( + store: EntityStore | undefined, + key: string, + unitId: number | undefined, + lists?: RefListCache, +): Promise<{ code: string } | undefined> { + if (unitId === undefined) return undefined; + const unit = await resolveViaMirrorOrList({ + store, + id: unitId, + kind: 'unit', + listKey: 'units', + lists, + fetchList: () => makeAltovizRequest('v1/units', key), + }); + if (!unit.code) { + throw new Error(`Altoviz unit ${unitId} has no code to reference it by.`); + } + return { code: unit.code }; +} + +export async function resolveVatRef( + store: EntityStore | undefined, + key: string, + vatId: number | undefined, + lists?: RefListCache, +): Promise<{ rate: number; region: string } | undefined> { + if (vatId === undefined) return undefined; + const vat = await resolveViaMirrorOrList({ + store, + id: vatId, + kind: 'VAT rate', + listKey: 'vats', + lists, + fetchList: () => makeAltovizRequest('v1/vats', key), + }); + if (vat.rate === undefined || vat.rate === null || !vat.region) { + throw new Error( + `Altoviz VAT rate ${vatId} has no rate/region to reference it by.`, + ); + } + return { rate: vat.rate, region: vat.region }; +} + +export async function resolveCustomerFamilyRef( + store: EntityStore | undefined, + key: string, + familyId: number | undefined, +): Promise<{ label: string; number: string } | undefined> { + if (familyId === undefined) return undefined; + const family = await resolveViaMirrorOrList({ + store, + id: familyId, + kind: 'customer family', + listKey: 'customerfamilies', + fetchList: () => + fetchAllPages('v1/customerfamilies', key), + }); + if (!family.label || !family.number) { + throw new Error( + `Altoviz customer family ${familyId} has no label/number to reference it by.`, + ); + } + return { label: family.label, number: family.number }; +} + +export async function resolveProductFamilyRef( + store: EntityStore | undefined, + key: string, + familyId: number | undefined, +): Promise<{ label: string; number: string } | undefined> { + if (familyId === undefined) return undefined; + const family = await resolveViaMirrorOrList({ + store, + id: familyId, + kind: 'product family', + listKey: 'productfamilies', + fetchList: () => + fetchAllPages('v1/productfamilies', key), + }); + if (!family.label || !family.number) { + throw new Error( + `Altoviz product family ${familyId} has no label/number to reference it by.`, + ); + } + return { label: family.label, number: family.number }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Read-modify-write support +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Best-effort mapping from the address shape Altoviz RETURNS + * (`city, countryIso, formattedAddress, inlineAddress, street, zipcode`) back + * to the shape it ACCEPTS as input (`line1, line2, zipCode, city, + * countryCode`), so a read-modify-write update can resend an address the + * caller did not touch without losing it. + * + * This is necessarily lossy: every captured response had `street: null`, so + * there is no observed source for `line1`/`line2` on the way back in. City, + * postal code and country carry across losslessly; a caller relying on a + * street line surviving an update they did not ask to change should pass the + * address explicitly. + */ +export function addressOutputToInput( + address: + | { + city?: string | null; + zipcode?: string | null; + countryIso?: string | null; + } + | null + | undefined, +): Record | undefined { + if (!address) return undefined; + return compactBody({ + city: address.city ?? undefined, + zipCode: address.zipcode ?? undefined, + countryCode: address.countryIso ?? undefined, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sale document lines +// ───────────────────────────────────────────────────────────────────────────── + +/** + * `SaleDocumentLine` has no `unitPrice`, `label` or `reference` field. Sending + * `unitPrice` is not rejected - it is silently IGNORED, and without a + * `productId` to price from, the line - and the whole document - totals zero + * with a 200. `taxExcludedPrice` is the field the provider actually reads. + * This schema exists so `unitPrice` cannot reach the transport at all: it is + * rejected here, client-side, with a message naming the field the API wants. + */ +export const AltovizLineInputSchema = z + .object({ + type: z + .enum(['Empty', 'Product', 'Service', 'Subtotal', 'Text', 'NewPage']) + .default('Service'), + productId: AltovizIdSchema.optional(), + description: z.string().optional(), + quantity: z.number().optional(), + /** The only price field Altoviz's line schema accepts. `unitPrice` is a documentation-shaped trap - see the module doc. */ + taxExcludedPrice: z.number().optional(), + unitId: AltovizIdSchema.optional().describe( + 'Resolved to {code} via the units mirror before the request is sent.', + ), + vatId: AltovizIdSchema.optional().describe( + 'Resolved to {rate, region} via the VAT mirror before the request is sent.', + ), + classificationId: AltovizIdSchema.optional(), + }) + .strict(); +export type AltovizLineInput = z.infer; + +export async function buildLine( + line: AltovizLineInput, + stores: { + units?: EntityStore; + vats?: EntityStore; + }, + key: string, + lists: RefListCache = new Map(), +): Promise> { + return compactBody({ + type: line.type, + productId: line.productId, + description: line.description, + quantity: line.quantity, + taxExcludedPrice: line.taxExcludedPrice, + unit: await resolveUnitRef(stores.units, key, line.unitId, lists), + vat: await resolveVatRef(stores.vats, key, line.vatId, lists), + classificationId: line.classificationId, + }); +} diff --git a/packages/altoviz/endpoints/suppliers.ts b/packages/altoviz/endpoints/suppliers.ts new file mode 100644 index 000000000..a96646d65 --- /dev/null +++ b/packages/altoviz/endpoints/suppliers.ts @@ -0,0 +1,144 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { cacheContact, evictContacts, fetchContactsForParent } from './persist'; +import { addressOutputToInput, buildPagingQuery, compactBody } from './shared'; +import type { + AltovizEndpointOutputs, + ContactOutput, + SupplierOutput, +} from './types'; + +/** No CREATE_SUPPLIER in the 67-op catalog, so get/update/delete reference an id this plugin cannot itself produce - see the PR notes on the missing counterpart. */ +export const get: AltovizEndpoints['suppliers']['get'] = async (ctx, input) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['suppliersGet'] + >(`v1/suppliers/{id}`, ctx.key, { path: { id: input.supplierId } }); + + await logEventFromContext( + ctx, + 'altoviz.suppliers.get', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const list: AltovizEndpoints['suppliers']['list'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['suppliersList'] + >('v1/suppliers', ctx.key, { query: buildPagingQuery(input) }); + + await logEventFromContext( + ctx, + 'altoviz.suppliers.list', + auditPayload(input), + 'completed', + ); + return result; +}; + +/** Same clearing PUT semantics as customers - read the current record and merge before sending. */ +export const update: AltovizEndpoints['suppliers']['update'] = async ( + ctx, + input, +) => { + const current = await makeAltovizRequest( + 'v1/suppliers/{id}', + ctx.key, + { path: { id: input.supplierId } }, + ); + + const body = compactBody({ + id: input.supplierId, + name: input.name ?? current.name, + firstName: input.firstName ?? current.firstName, + lastName: input.lastName ?? current.lastName, + email: input.email ?? current.email, + phone: input.phone ?? current.phone, + cellPhone: input.cellPhone ?? current.cellPhone, + title: input.title ?? current.title, + number: input.number ?? current.number, + internalId: input.internalId ?? current.internalId, + internalNotes: input.internalNotes ?? current.internalNotes, + address: input.address ?? addressOutputToInput(current.address), + defaultPaymentMethod: + input.defaultPaymentMethod ?? current.defaultPaymentMethod, + companyInformations: current.companyInformations, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['suppliersUpdate'] + >('v1/suppliers/{id}', ctx.key, { + method: 'PUT', + body, + path: { id: input.supplierId }, + }); + + await logEventFromContext( + ctx, + 'altoviz.suppliers.update', + auditPayload(input), + 'completed', + ); + return result; +}; + +export const remove: AltovizEndpoints['suppliers']['delete'] = async ( + ctx, + input, +) => { + const contacts = await fetchContactsForParent( + () => + makeAltovizRequest( + 'v1/suppliers/{id}/contacts', + ctx.key, + { path: { id: input.supplierId } }, + ), + `supplier ${input.supplierId}`, + ); + + await makeAltovizRequest('v1/suppliers/{id}', ctx.key, { + method: 'DELETE', + path: { id: input.supplierId }, + }); + + await evictContacts( + ctx.db.contacts, + contacts, + `supplier ${input.supplierId}`, + ); + + await logEventFromContext( + ctx, + 'altoviz.suppliers.delete', + auditPayload(input), + 'completed', + ); + return { deleted: true, id: input.supplierId }; +}; + +export const getContacts: AltovizEndpoints['suppliers']['getContacts'] = async ( + ctx, + input, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['suppliersGetContacts'] + >(`v1/suppliers/{id}/contacts`, ctx.key, { + path: { id: input.supplierId }, + }); + + for (const contact of result) await cacheContact(ctx.db.contacts, contact); + + await logEventFromContext( + ctx, + 'altoviz.suppliers.getContacts', + auditPayload(input), + 'completed', + ); + return result; +}; diff --git a/packages/altoviz/endpoints/types.ts b/packages/altoviz/endpoints/types.ts new file mode 100644 index 000000000..70687d8ad --- /dev/null +++ b/packages/altoviz/endpoints/types.ts @@ -0,0 +1,1484 @@ +import { z } from 'zod'; +import { + AltovizDateSchema, + AltovizIdSchema, + AltovizLineInputSchema, + PagingInputSchema, +} from './shared'; + +/** + * Response shapes captured live on 2026-08-15 against a seeded tenant + * (`tools/altoviz-shapes.json`, 19 entities). Every output schema below is + * `.loose()` with only `id` required: a single capture cannot prove a field + * is always present, and the provider adds fields the OpenAPI document does + * not declare (the invoice response carries `eInvoicing*` and + * `cancelledCredit*` fields, for one). + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Shared sub-shapes +// ───────────────────────────────────────────────────────────────────────────── + +const AddressOutputSchema = z + .object({ + city: z.string().nullable().optional(), + countryIso: z.string().nullable().optional(), + countryName: z.string().nullable().optional(), + formattedAddress: z.string().nullable().optional(), + inlineAddress: z.string().nullable().optional(), + street: z.string().nullable().optional(), + zipcode: z.string().nullable().optional(), + }) + .loose(); + +const AddressInputSchema = z + .object({ + line1: z.string().optional(), + line2: z.string().optional(), + zipCode: z.string().optional(), + city: z.string().optional(), + countryCode: z.string().optional(), + }) + .loose(); + +const ContactRefOutputSchema = z + .object({ + cellPhone: z.string().nullable().optional(), + companyName: z.string().nullable().optional(), + displayName: z.string().nullable().optional(), + email: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + function: z.string().nullable().optional(), + invertedDisplayName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + service: z.string().nullable().optional(), + title: z.string().nullable().optional(), + }) + .loose(); + +const VatRefOutputSchema = z + .object({ + default: z.boolean().nullable().optional(), + id: z.number().nullable().optional(), + label: z.string().nullable().optional(), + rate: z.number().nullable().optional(), + region: z.string().nullable().optional(), + }) + .loose(); + +const UnitRefOutputSchema = z + .object({ + code: z.string().nullable().optional(), + conversion: z.number().nullable().optional(), + decimals: z.number().nullable().optional(), + id: z.number().nullable().optional(), + name: z.string().nullable().optional(), + type: z.string().nullable().optional(), + }) + .loose(); + +const FamilyRefOutputSchema = z + .object({ + id: z.number().nullable().optional(), + label: z.string().nullable().optional(), + number: z.string().nullable().optional(), + }) + .loose(); + +const DiscountOutputSchema = z + .object({ + type: z.string().nullable().optional(), + value: z.number().nullable().optional(), + }) + .loose(); + +const CommitmentOutputSchema = z + .object({ + amount: z.number().nullable().optional(), + date: z.string().nullable().optional(), + dueAmount: z.number().nullable().optional(), + id: z.number().nullable().optional(), + thirdId: z.number().nullable().optional(), + }) + .loose(); + +const DocumentVatSummaryOutputSchema = z + .object({ + taxExcludedAmount: z.number().nullable().optional(), + vat: VatRefOutputSchema.nullable().optional(), + vatAmount: z.number().nullable().optional(), + }) + .loose(); + +/** The product embedded inside a sale document line - a snapshot at the time the line was priced, not a live reference. */ +const LineProductOutputSchema = z + .object({ + active: z.boolean().nullable().optional(), + defaultQuantity: z.number().nullable().optional(), + description: z.string().nullable().optional(), + family: FamilyRefOutputSchema.nullable().optional(), + id: z.number().nullable().optional(), + imageUrl: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + internalNotes: z.string().nullable().optional(), + isUnitPriceTaxIncluded: z.boolean().nullable().optional(), + name: z.string().nullable().optional(), + number: z.string().nullable().optional(), + purchasePrice: z.number().nullable().optional(), + type: z.string().nullable().optional(), + unit: UnitRefOutputSchema.nullable().optional(), + unitPrice: z.number().nullable().optional(), + vat: VatRefOutputSchema.nullable().optional(), + }) + .loose(); + +const LineOutputSchema = z + .object({ + classificationId: z.number().nullable().optional(), + date: z.string().nullable().optional(), + description: z.string().nullable().optional(), + discount: DiscountOutputSchema.nullable().optional(), + id: z.number().nullable().optional(), + marginAmount: z.number().nullable().optional(), + marginRate: z.number().nullable().optional(), + product: LineProductOutputSchema.nullable().optional(), + productId: z.number().nullable().optional(), + productInternalId: z.string().nullable().optional(), + productNumber: z.string().nullable().optional(), + purchasePrice: z.number().nullable().optional(), + quantity: z.number().nullable().optional(), + taxExcludedAmount: z.number().nullable().optional(), + taxExcludedPrice: z.number().nullable().optional(), + taxIncludedAmount: z.number().nullable().optional(), + taxIncludedPrice: z.number().nullable().optional(), + type: z.string().nullable().optional(), + unit: UnitRefOutputSchema.nullable().optional(), + vat: VatRefOutputSchema.nullable().optional(), + }) + .loose(); + +/** Shared by invoices, credits and quotes - the "sale document" shape Altoviz returns for all three. */ +const SaleDocumentOutputSchema = z + .object({ + id: z.number(), + number: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + date: z.string().nullable().optional(), + subject: z.string().nullable().optional(), + customerId: z.number().nullable().optional(), + customerName: z.string().nullable().optional(), + customerNumber: z.string().nullable().optional(), + customerType: z.string().nullable().optional(), + customerElectronicAddress: z.string().nullable().optional(), + customerOrderReference: z.string().nullable().optional(), + customerSiret: z.string().nullable().optional(), + customerVatNumber: z.string().nullable().optional(), + billingAddress: AddressOutputSchema.nullable().optional(), + shippingAddress: AddressOutputSchema.nullable().optional(), + billingContact: ContactRefOutputSchema.nullable().optional(), + shippingContact: ContactRefOutputSchema.nullable().optional(), + headerNotes: z.string().nullable().optional(), + footerNotes: z.string().nullable().optional(), + internalNotes: z.string().nullable().optional(), + lines: z.array(LineOutputSchema).nullable().optional(), + globalDiscount: DiscountOutputSchema.nullable().optional(), + shippingAmount: z.number().nullable().optional(), + shippingVat: z.unknown().nullable().optional(), + balance: z.number().nullable().optional(), + grossTaxExcludedAmount: z.number().nullable().optional(), + taxAmount: z.number().nullable().optional(), + taxExcludedAmount: z.number().nullable().optional(), + taxIncludedAmount: z.number().nullable().optional(), + useTaxIncludedPrices: z.boolean().nullable().optional(), + liableToVat: z.boolean().nullable().optional(), + vatMode: z.string().nullable().optional(), + vatNote: z.string().nullable().optional(), + vatReverseCharge: z.boolean().nullable().optional(), + region: z.string().nullable().optional(), + vats: z.array(DocumentVatSummaryOutputSchema).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + pdfUrl: z.string().nullable().optional(), + publicLink: z.string().nullable().optional(), + sentAt: z.string().nullable().optional(), + commitments: z.array(CommitmentOutputSchema).nullable().optional(), + overdue: z.number().nullable().optional(), + vendorReference: z.string().nullable().optional(), + }) + .loose(); + +// ───────────────────────────────────────────────────────────────────────────── +// account +// ───────────────────────────────────────────────────────────────────────────── + +const EmptyInputSchema = z.object({}).strict(); +export type EmptyInput = z.infer; + +const HelloOutputSchema = z + .object({ + apiKeyName: z.string().nullable().optional(), + companyName: z.string().nullable().optional(), + message: z.string().nullable().optional(), + serverTimestamp: z.string().nullable().optional(), + url: z.string().nullable().optional(), + }) + .loose(); +export type HelloOutput = z.infer; + +const CurrentUserOutputSchema = z + .object({ + userId: z.string(), + displayName: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + email: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + profile: z.string().nullable().optional(), + status: z.string().nullable().optional(), + }) + .loose(); +export type CurrentUserOutput = z.infer; + +/** Settings is a large, mostly-read-only configuration object - deliberately typed loose rather than field-by-field. */ +const SettingsOutputSchema = z.record(z.string(), z.unknown()); +export type SettingsOutput = z.infer; + +const UnitOutputSchema = z + .object({ + id: z.number(), + code: z.string().nullable().optional(), + name: z.string().nullable().optional(), + type: z.string().nullable().optional(), + conversion: z.number().nullable().optional(), + decimals: z.number().nullable().optional(), + }) + .loose(); +export type UnitOutput = z.infer; + +const VatOutputSchema = VatRefOutputSchema.extend({ id: z.number() }); +export type VatOutput = z.infer; + +const GetClassificationsInputSchema = z.object({ + /** Sale | Expense | Other - validated server-side, an unknown value is a 400. */ + type: z.enum(['Sale', 'Expense', 'Other']).optional(), +}); +export type GetClassificationsInput = z.infer< + typeof GetClassificationsInputSchema +>; + +const ClassificationOutputSchema = z + .object({ + id: z.number(), + label: z.string().nullable().optional(), + description: z.string().nullable().optional(), + type: z.string().nullable().optional(), + accountNumber: z.string().nullable().optional(), + isProduct: z.boolean().nullable().optional(), + isService: z.boolean().nullable().optional(), + defaultVat: VatRefOutputSchema.nullable().optional(), + microBusinessDeclarationType: z.string().nullable().optional(), + }) + .loose(); +export type ClassificationOutput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// customers +// ───────────────────────────────────────────────────────────────────────────── + +/** The enum the API actually accepts - NOT the Company/Individual the catalog description documents. Sending either documented value is a 400. */ +const CustomerTypeSchema = z.enum(['Business', 'Consumer', 'Government']); + +const CustomerOutputSchema = z + .object({ + id: z.number(), + type: z.string().nullable().optional(), + companyName: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + cellPhone: z.string().nullable().optional(), + title: z.string().nullable().optional(), + number: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + internalNotes: z.string().nullable().optional(), + active: z.boolean().nullable().optional(), + billingAddress: AddressOutputSchema.nullable().optional(), + shippingAddress: AddressOutputSchema.nullable().optional(), + billingOptions: z.record(z.string(), z.unknown()).nullable().optional(), + companyInformations: z + .record(z.string(), z.unknown()) + .nullable() + .optional(), + family: FamilyRefOutputSchema.nullable().optional(), + }) + .loose(); +export type CustomerOutput = z.infer; + +const CreateCustomerInputSchema = z.object({ + type: CustomerTypeSchema, + companyName: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + cellPhone: z.string().optional(), + title: z.string().optional(), + /** Optional unless the customer numbering sequence has never been used on this tenant - then required, or the create is a 400 "La numerotation des Clients n'a pas ete initialisee.". */ + number: z.string().optional(), + internalId: z.string().optional(), + active: z.boolean().optional(), + billingAddress: AddressInputSchema.optional(), + shippingAddress: AddressInputSchema.optional(), + billingOptions: z.record(z.string(), z.unknown()).optional(), + companyInformations: z.record(z.string(), z.unknown()).optional(), + /** Resolved to {label, number} via the customer-family mirror - id: {id} is silently dropped by the API. */ + familyId: AltovizIdSchema.optional(), + internalNotes: z.string().optional(), +}); +export type CreateCustomerInput = z.infer; + +/** + * PUT clears every field the body omits - confirmed live: a partial update + * cleared eleven fields to change one. Every field here is therefore optional + * on the wire but the handler always reads the current record first and + * merges the caller's fields over it, so a caller supplying one field never + * loses the rest. + */ +const UpdateCustomerInputSchema = z.object({ + customerId: AltovizIdSchema, + type: CustomerTypeSchema.optional(), + companyName: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + cellPhone: z.string().optional(), + title: z.string().optional(), + number: z.string().optional(), + internalId: z.string().optional(), + active: z.boolean().optional(), + billingAddress: AddressInputSchema.optional(), + shippingAddress: AddressInputSchema.optional(), + billingOptions: z.record(z.string(), z.unknown()).optional(), + companyInformations: z.record(z.string(), z.unknown()).optional(), + familyId: AltovizIdSchema.optional(), + internalNotes: z.string().optional(), +}); +export type UpdateCustomerInput = z.infer; + +const DeleteCustomerInputSchema = z.object({ customerId: AltovizIdSchema }); +export type DeleteCustomerInput = z.infer; + +const DeletedResultSchema = z.object({ + deleted: z.literal(true), + id: z.number(), +}); +export type DeletedResult = z.infer; + +const GetCustomerInputSchema = z.object({ customerId: AltovizIdSchema }); +export type GetCustomerInput = z.infer; + +const GetCustomerByInternalIdInputSchema = z.object({ internalId: z.string() }); +export type GetCustomerByInternalIdInput = z.infer< + typeof GetCustomerByInternalIdInputSchema +>; + +const FindCustomerInputSchema = z.object({ + email: z.string().optional(), + internalId: z.string().optional(), + number: z.string().optional(), +}); +export type FindCustomerInput = z.infer; + +const ListCustomersInputSchema = z.object(PagingInputSchema); +export type ListCustomersInput = z.infer; + +const GetCustomerContactsInputSchema = z.object({ + customerId: AltovizIdSchema, +}); +export type GetCustomerContactsInput = z.infer< + typeof GetCustomerContactsInputSchema +>; + +const ContactOutputSchema = z + .object({ + id: z.number(), + displayName: z.string().nullable().optional(), + invertedDisplayName: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + companyName: z.string().nullable().optional(), + email: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + cellPhone: z.string().nullable().optional(), + function: z.string().nullable().optional(), + service: z.string().nullable().optional(), + title: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + isMain: z.boolean().nullable().optional(), + }) + .loose(); +export type ContactOutput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// customerFamilies +// ───────────────────────────────────────────────────────────────────────────── + +const CustomerFamilyOutputSchema = z + .object({ + id: z.number(), + label: z.string().nullable().optional(), + number: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + }) + .loose(); +export type CustomerFamilyOutput = z.infer; + +const CreateCustomerFamilyInputSchema = z.object({ + label: z.string().min(1), + number: z.string().optional(), + internalId: z.string().optional(), +}); +export type CreateCustomerFamilyInput = z.infer< + typeof CreateCustomerFamilyInputSchema +>; + +const GetCustomerFamilyInputSchema = z.object({ familyId: AltovizIdSchema }); +export type GetCustomerFamilyInput = z.infer< + typeof GetCustomerFamilyInputSchema +>; + +/** No cascade: deleting a family that still holds a member is a 409, not a delete - confirmed live. */ +const DeleteCustomerFamilyInputSchema = z.object({ familyId: AltovizIdSchema }); +export type DeleteCustomerFamilyInput = z.infer< + typeof DeleteCustomerFamilyInputSchema +>; + +const ListCustomerFamiliesInputSchema = z.object(PagingInputSchema); +export type ListCustomerFamiliesInput = z.infer< + typeof ListCustomerFamiliesInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// suppliers +// ───────────────────────────────────────────────────────────────────────────── + +const SupplierOutputSchema = z + .object({ + id: z.number(), + name: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + email: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + cellPhone: z.string().nullable().optional(), + title: z.string().nullable().optional(), + number: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + internalNotes: z.string().nullable().optional(), + active: z.boolean().nullable().optional(), + address: AddressOutputSchema.nullable().optional(), + defaultPaymentMethod: z.string().nullable().optional(), + companyInformations: z + .record(z.string(), z.unknown()) + .nullable() + .optional(), + createdAt: z.string().nullable().optional(), + createdById: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + updatedById: z.string().nullable().optional(), + }) + .loose(); +export type SupplierOutput = z.infer; + +const GetSupplierInputSchema = z.object({ supplierId: AltovizIdSchema }); +export type GetSupplierInput = z.infer; + +const ListSuppliersInputSchema = z.object(PagingInputSchema); +export type ListSuppliersInput = z.infer; + +/** Same clearing PUT semantics as customers - the handler reads the current record and merges. */ +const UpdateSupplierInputSchema = z.object({ + supplierId: AltovizIdSchema, + name: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + cellPhone: z.string().optional(), + title: z.string().optional(), + number: z.string().optional(), + internalId: z.string().optional(), + address: AddressInputSchema.optional(), + defaultPaymentMethod: z.string().optional(), + internalNotes: z.string().optional(), +}); +export type UpdateSupplierInput = z.infer; + +const DeleteSupplierInputSchema = z.object({ supplierId: AltovizIdSchema }); +export type DeleteSupplierInput = z.infer; + +const GetSupplierContactsInputSchema = z.object({ + supplierId: AltovizIdSchema, +}); +export type GetSupplierContactsInput = z.infer< + typeof GetSupplierContactsInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// contacts +// ───────────────────────────────────────────────────────────────────────────── + +/** No customerId field exists on this route - confirmed live. There is no way to attach a standalone contact to a customer through this operation. */ +const CreateContactInputSchema = z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + cellPhone: z.string().optional(), + companyName: z.string().optional(), + function: z.string().optional(), + service: z.string().optional(), + title: z.string().optional(), + displayName: z.string().optional(), + invertedDisplayName: z.string().optional(), + internalId: z.string().optional(), +}); +export type CreateContactInput = z.infer; + +const GetContactInputSchema = z.object({ contactId: AltovizIdSchema }); +export type GetContactInput = z.infer; + +const FindContactInputSchema = z.object({ + email: z.string().optional(), + internalId: z.string().optional(), +}); +export type FindContactInput = z.infer; + +const ListContactsInputSchema = z.object(PagingInputSchema); +export type ListContactsInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// colleagues +// ───────────────────────────────────────────────────────────────────────────── + +const ColleagueOutputSchema = z + .object({ + id: z.number(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + cellPhone: z.string().nullable().optional(), + title: z.string().nullable().optional(), + number: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + isPartner: z.boolean().nullable().optional(), + initialPartnerBalance: z.number().nullable().optional(), + homecareServiceNumber: z.string().nullable().optional(), + userId: z.string().nullable().optional(), + metadatas: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .loose(); +export type ColleagueOutput = z.infer; + +const GetColleagueInputSchema = z.object({ colleagueId: AltovizIdSchema }); +export type GetColleagueInput = z.infer; + +const ListColleaguesInputSchema = z.object(PagingInputSchema); +export type ListColleaguesInput = z.infer; + +/** + * A PARTIAL body here is a 500, not a 400 - confirmed live. read-modify-write + * happens to be the fix for both the clearing-PUT problem elsewhere and this + * 500, since the handler always sends the full merged record back. + */ +const UpdateColleagueInputSchema = z.object({ + colleagueId: AltovizIdSchema, + firstName: z.string().optional(), + lastName: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + cellPhone: z.string().optional(), + title: z.string().optional(), + number: z.string().optional(), + internalId: z.string().optional(), + isPartner: z.boolean().optional(), + initialPartnerBalance: z.number().optional(), + homecareServiceNumber: z.string().optional(), + userId: z.string().optional(), + metadatas: z.record(z.string(), z.unknown()).optional(), +}); +export type UpdateColleagueInput = z.infer; + +const DeleteColleagueInputSchema = z.object({ colleagueId: AltovizIdSchema }); +export type DeleteColleagueInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// webhookSubscriptions +// ───────────────────────────────────────────────────────────────────────────── + +const ListWebhooksInputSchema = EmptyInputSchema; +export type ListWebhooksInput = z.infer; + +const WebhookOutputSchema = z + .object({ + id: z.number(), + name: z.string().nullable().optional(), + url: z.string().nullable().optional(), + types: z.array(z.string()).nullable().optional(), + secretKey: z.string().nullable().optional(), + }) + .loose(); +export type WebhookOutput = z.infer; + +const AltovizWebhookEventType = z.enum([ + 'CustomerCreated', + 'CustomerUpdated', + 'CustomerDeleted', + 'ContactCreated', + 'ContactUpdated', + 'ContactDeleted', + 'ProductCreated', + 'ProductUpdated', + 'ProductDeleted', + 'InvoiceCreated', + 'InvoiceUpdated', + 'InvoiceDeleted', + 'QuoteCreated', + 'QuoteUpdated', + 'QuoteDeleted', +]); + +const RegisterWebhookInputSchema = z.object({ + name: z.string().min(1), + url: z.string().min(1), + types: z.array(AltovizWebhookEventType).min(1), + secretKey: z.string().optional(), +}); +export type RegisterWebhookInput = z.infer; + +/** + * REGISTER_WEBHOOK answers 201 with id: 0 - the real id only appears in + * LIST_WEBHOOKS, and that list is eventually consistent (a deleted webhook + * reappeared for one call about two seconds after its delete). The output + * schema follows what the provider actually sends rather than inventing a + * meaningful id. + */ +const RegisterWebhookOutputSchema = WebhookOutputSchema; +export type RegisterWebhookOutput = z.infer; + +/** + * Both `id` and `url` are optional in the provider's own spec, so a call + * supplying neither could delete broadly - this was deliberately never + * probed live. Exactly one is required here, enforced before the request is + * built. + */ +const UnregisterWebhookInputSchema = z + .object({ + webhookId: AltovizIdSchema.optional(), + url: z.string().optional(), + }) + .refine((v) => (v.webhookId !== undefined) !== (v.url !== undefined), { + message: 'Provide exactly one of webhookId or url to unregister a webhook.', + }); +export type UnregisterWebhookInput = z.infer< + typeof UnregisterWebhookInputSchema +>; + +/** + * Unregister echoes back whichever key the caller deleted by. Deleting by url + * carries no id, so `id` stays absent rather than being faked as 0 (which + * collides with the register placeholder) - the shared DeletedResult cannot + * express that, hence a dedicated shape. + */ +const UnregisterWebhookOutputSchema = z.object({ + deleted: z.literal(true), + id: z.number().optional(), + url: z.string().optional(), +}); +export type UnregisterWebhookOutput = z.infer< + typeof UnregisterWebhookOutputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// products +// ───────────────────────────────────────────────────────────────────────────── + +const ProductOutputSchema = z + .object({ + id: z.number(), + name: z.string().nullable().optional(), + number: z.string().nullable().optional(), + description: z.string().nullable().optional(), + type: z.string().nullable().optional(), + unitPrice: z.number().nullable().optional(), + purchasePrice: z.number().nullable().optional(), + isUnitPriceTaxIncluded: z.boolean().nullable().optional(), + defaultQuantity: z.number().nullable().optional(), + unit: UnitRefOutputSchema.nullable().optional(), + vat: VatRefOutputSchema.nullable().optional(), + family: FamilyRefOutputSchema.nullable().optional(), + imageUrl: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + internalNotes: z.string().nullable().optional(), + active: z.boolean().nullable().optional(), + }) + .loose(); +export type ProductOutput = z.infer; + +const CreateProductInputSchema = z.object({ + name: z.string().min(1), + number: z.string().optional(), + description: z.string().optional(), + type: z.enum(['Product', 'Service', 'Text']), + unitPrice: z.number().optional(), + purchasePrice: z.number().optional(), + isUnitPriceTaxIncluded: z.boolean().optional(), + defaultQuantity: z.number().optional(), + /** Resolved to {code} via the units mirror - id: {id} is a documented-but-wrong shape here. */ + unitId: AltovizIdSchema.optional(), + /** Resolved to {rate, region} via the VAT mirror - vat: {id} is a 400 "La TVA n'existe pas.". */ + vatId: AltovizIdSchema.optional(), + /** Resolved to {label, number} via the product-family mirror. */ + familyId: AltovizIdSchema.optional(), + internalId: z.string().optional(), + internalNotes: z.string().optional(), + active: z.boolean().optional(), +}); +export type CreateProductInput = z.infer; + +const DeleteProductInputSchema = z.object({ productId: AltovizIdSchema }); +export type DeleteProductInput = z.infer; + +const GetProductInputSchema = z.object({ productId: AltovizIdSchema }); +export type GetProductInput = z.infer; + +/** Same route as FindProductByNumberOrId - an empty call 400s "Number or internal ID have to be defined", so require `number` client-side. */ +const FindProductInputSchema = z + .object({ number: z.string().optional() }) + .refine((v) => v.number !== undefined, { message: 'Provide number.' }); +export type FindProductInput = z.infer; + +/** Superset of FindProduct - same route (`GET /v1/products/find`), and requires at least one parameter or the API 400s "Number or internal ID have to be defined". */ +const FindProductByNumberOrIdInputSchema = z + .object({ + number: z.string().optional(), + internalId: z.string().optional(), + }) + .refine((v) => v.number !== undefined || v.internalId !== undefined, { + message: 'Provide number or internalId.', + }); +export type FindProductByNumberOrIdInput = z.infer< + typeof FindProductByNumberOrIdInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// productFamilies +// ───────────────────────────────────────────────────────────────────────────── + +const ProductFamilyOutputSchema = z + .object({ + id: z.number(), + label: z.string().nullable().optional(), + number: z.string().nullable().optional(), + }) + .loose(); +export type ProductFamilyOutput = z.infer; + +const CreateProductFamilyInputSchema = z.object({ + label: z.string().min(1), + number: z.string().optional(), +}); +export type CreateProductFamilyInput = z.infer< + typeof CreateProductFamilyInputSchema +>; + +const GetProductFamilyInputSchema = z.object({ familyId: AltovizIdSchema }); +export type GetProductFamilyInput = z.infer; + +const DeleteProductFamilyInputSchema = z.object({ familyId: AltovizIdSchema }); +export type DeleteProductFamilyInput = z.infer< + typeof DeleteProductFamilyInputSchema +>; + +const ListProductFamiliesInputSchema = z.object(PagingInputSchema); +export type ListProductFamiliesInput = z.infer< + typeof ListProductFamiliesInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// saleInvoices +// ───────────────────────────────────────────────────────────────────────────── + +const SaleInvoiceOutputSchema = SaleDocumentOutputSchema.extend({ + isDraft: z.boolean().nullable().optional(), + isPaid: z.boolean().nullable().optional(), + isCancelled: z.boolean().nullable().optional(), + isProforma: z.boolean().nullable().optional(), + cancellationCreditId: z.number().nullable().optional(), + cancellationCreditNumber: z.string().nullable().optional(), + // Observed live but absent from the published schema; all captures were null, + // so retain the fields without pretending their non-null types are known. + cancelledCreditId: z.unknown().nullable().optional(), + cancelledCreditNumber: z.unknown().nullable().optional(), + eInvoicingInvoiceId: z.unknown().nullable().optional(), + eInvoicingProviderId: z.unknown().nullable().optional(), + eInvoicingStatus: z.unknown().nullable().optional(), + replacedBy: z.number().nullable().optional(), +}); +export type SaleInvoiceOutput = z.infer; + +const CreateSaleInvoiceInputSchema = z.object({ + customerId: AltovizIdSchema, + date: AltovizDateSchema, + subject: z.string().optional(), + headerNotes: z.string().optional(), + footerNotes: z.string().optional(), + /** Required in practice: an invoice without lines is a 400 "The specified condition was not met for 'Lines'.". */ + lines: z.array(AltovizLineInputSchema).min(1), + globalDiscount: z + .object({ type: z.enum(['Percent', 'Fixed']), value: z.number() }) + .optional(), + shippingAmount: z.number().optional(), + vatMode: z.enum(['Auto', 'Debit', 'Collection']).optional(), + region: z.enum(['FR', 'EU', 'IE', 'DOM', 'Corse', 'Monaco']).optional(), + liableToVat: z.boolean().optional(), + vatReverseCharge: z.boolean().optional(), + useTaxIncludedPrices: z.boolean().optional(), + isDraft: z.boolean().optional(), + internalId: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateSaleInvoiceInput = z.infer< + typeof CreateSaleInvoiceInputSchema +>; + +const GetSaleInvoiceInputSchema = z.object({ invoiceId: AltovizIdSchema }); +export type GetSaleInvoiceInput = z.infer; + +const FindSaleInvoiceInputSchema = z.object({ + internalId: z.string().optional(), +}); +export type FindSaleInvoiceInput = z.infer; + +const ListSaleInvoicesInputSchema = z.object({ + ...PagingInputSchema, + from: AltovizDateSchema.optional(), + to: AltovizDateSchema.optional(), + customerId: AltovizIdSchema.optional(), + status: z.enum(['Draft', 'Incoming', 'Expired', 'Paid', 'ToSend']).optional(), + includeCancelled: z.boolean().optional(), +}); +export type ListSaleInvoicesInput = z.infer; + +/** Drafts only - a finalized invoice is expected to refuse the delete, though nothing here was finalized to confirm the exact status. */ +const DeleteSaleInvoiceInputSchema = z.object({ invoiceId: AltovizIdSchema }); +export type DeleteSaleInvoiceInput = z.infer< + typeof DeleteSaleInvoiceInputSchema +>; + +const DownloadSaleInvoiceInputSchema = z.object({ invoiceId: AltovizIdSchema }); +export type DownloadSaleInvoiceInput = z.infer< + typeof DownloadSaleInvoiceInputSchema +>; + +/** The provider answers a real application/pdf; the shared transport decodes it with response.text(), which is lossy for binary - see client.ts. */ +const DownloadOutputSchema = z.object({ + contentType: z.string().nullable(), + body: z + .string() + .describe( + 'PDF bytes decoded through response.text() by the shared transport - may not be byte-exact. See the core-limitation note in client.ts.', + ), +}); +export type DownloadOutput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// saleCredits +// ───────────────────────────────────────────────────────────────────────────── + +const SaleCreditOutputSchema = SaleDocumentOutputSchema.extend({ + isDraft: z.boolean().nullable().optional(), + isPaid: z.boolean().nullable().optional(), + isCancelled: z.boolean().nullable().optional(), + /** The provider's own spelling, typo included - matched exactly on the wire and in the response. */ + cancelledInvoicetId: z.number().nullable().optional(), + cancelledInvoicetNumber: z.string().nullable().optional(), + cancellationInvoiceId: z.number().nullable().optional(), + cancellationInvoiceNumber: z.string().nullable().optional(), + // Returned by the live API but omitted from the published credit schema. + replacedBy: z.unknown().nullable().optional(), +}); +export type SaleCreditOutput = z.infer; + +const CreateSaleCreditInputSchema = z.object({ + customerId: AltovizIdSchema, + /** Provider spelling, typo included - do not "fix" it to cancelledInvoiceId. */ + cancelledInvoicetId: AltovizIdSchema.optional(), + cancelledInvoicetNumber: z.string().optional(), + date: AltovizDateSchema, + subject: z.string().optional(), + headerNotes: z.string().optional(), + footerNotes: z.string().optional(), + lines: z.array(AltovizLineInputSchema).min(1), + globalDiscount: z + .object({ type: z.enum(['Percent', 'Fixed']), value: z.number() }) + .optional(), + vatMode: z.enum(['Auto', 'Debit', 'Collection']).optional(), + region: z.enum(['FR', 'EU', 'IE', 'DOM', 'Corse', 'Monaco']).optional(), + isDraft: z.boolean().optional(), + internalId: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateSaleCreditInput = z.infer; + +const UpdateSaleCreditInputSchema = z.object({ + creditId: AltovizIdSchema, + customerId: AltovizIdSchema.optional(), + date: AltovizDateSchema.optional(), + subject: z.string().optional(), + headerNotes: z.string().optional(), + footerNotes: z.string().optional(), + /** Drafts only. Lines must be resent in full or the credit is emptied - this is the same clearing-write behaviour as PUT elsewhere. */ + lines: z.array(AltovizLineInputSchema).min(1), + isDraft: z.boolean().optional(), +}); +export type UpdateSaleCreditInput = z.infer; + +const GetSaleCreditInputSchema = z.object({ creditId: AltovizIdSchema }); +export type GetSaleCreditInput = z.infer; + +const FindSaleCreditInputSchema = z.object({ + internalId: z.string().optional(), +}); +export type FindSaleCreditInput = z.infer; + +const ListSaleCreditsInputSchema = z.object({ + ...PagingInputSchema, + from: AltovizDateSchema.optional(), + to: AltovizDateSchema.optional(), + customerId: AltovizIdSchema.optional(), +}); +export type ListSaleCreditsInput = z.infer; + +const DeleteSaleCreditInputSchema = z.object({ creditId: AltovizIdSchema }); +export type DeleteSaleCreditInput = z.infer; + +const DownloadSaleCreditInputSchema = z.object({ creditId: AltovizIdSchema }); +export type DownloadSaleCreditInput = z.infer< + typeof DownloadSaleCreditInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// saleQuotes +// ───────────────────────────────────────────────────────────────────────────── + +const SaleQuoteOutputSchema = SaleDocumentOutputSchema.extend({ + status: z.string().nullable().optional(), + validityDate: z.string().nullable().optional(), + deposit: DiscountOutputSchema.nullable().optional(), + acceptedAt: z.string().nullable().optional(), + refusedAt: z.string().nullable().optional(), +}); +export type SaleQuoteOutput = z.infer; + +const FindSaleQuoteInputSchema = z.object({ + internalId: z.string().optional(), +}); +export type FindSaleQuoteInput = z.infer; + +/** + * The spec emits Status.From / Status.Status.From / Status.Status.Status - a + * generator artefact. Live, Status is silently ignored and Status.Status is a + * 500, so no status filter is exposed here; it would not do anything. + */ +const ListSaleQuotesInputSchema = z.object({ + ...PagingInputSchema, + from: AltovizDateSchema.optional(), + to: AltovizDateSchema.optional(), + customerId: AltovizIdSchema.optional(), +}); +export type ListSaleQuotesInput = z.infer; + +/** Deleting a quote that does not exist ALSO returns 200 - confirmed live - so this operation cannot report a miss. */ +const DeleteSaleQuoteInputSchema = z.object({ quoteId: AltovizIdSchema }); +export type DeleteSaleQuoteInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// receipts +// ───────────────────────────────────────────────────────────────────────────── + +const ReceiptLinkSchema = z.object({ + type: z.enum(['Commitment', 'Invoice', 'Credit']), + id: AltovizIdSchema, +}); + +const ReceiptOutputSchema = z + .object({ + id: z.number(), + amount: z.number().nullable().optional(), + date: z.string().nullable().optional(), + paymentMethod: z.string().nullable().optional(), + status: z.string().nullable().optional(), + reference: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + customerId: z.number().nullable().optional(), + customerName: z.string().nullable().optional(), + customerNumber: z.string().nullable().optional(), + customerInternalId: z.string().nullable().optional(), + internalId: z.string().nullable().optional(), + links: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .loose(); +export type ReceiptOutput = z.infer; + +/** + * `links` attaches the receipt to a Commitment | Invoice | Credit - but + * confirmed live, linking to a DRAFT document is refused ("Impossible + * d'encaisser un document en brouillon ... vous devez le finaliser au + * prealable"). Finalize is outside this plugin's scope, so `links` mostly + * cannot be used through catalog operations alone; the receipt still creates + * fine standalone. + */ +const CreateReceiptInputSchema = z.object({ + amount: z.number(), + date: AltovizDateSchema, + paymentMethod: z.enum([ + 'Transfer', + 'Order', + 'Check', + 'Cash', + 'Card', + 'Bill', + 'Usec', + 'Other', + ]), + status: z.enum(['Success', 'Pending', 'Failed']).optional(), + reference: z.string().optional(), + notes: z.string().optional(), + customerId: AltovizIdSchema.optional(), + customerName: z.string().optional(), + customerNumber: z.string().optional(), + customerInternalId: z.string().optional(), + links: z.array(ReceiptLinkSchema).optional(), + internalId: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateReceiptInput = z.infer; + +/** customerId (or number / internalId) is required even on update - confirmed live: "Customer ID, number or internal ID must be defined" without it. Read-modify-write, same as every other update in this plugin. */ +const UpdateReceiptInputSchema = z.object({ + receiptId: AltovizIdSchema, + amount: z.number().optional(), + date: AltovizDateSchema.optional(), + paymentMethod: z + .enum([ + 'Transfer', + 'Order', + 'Check', + 'Cash', + 'Card', + 'Bill', + 'Usec', + 'Other', + ]) + .optional(), + status: z.enum(['Success', 'Pending', 'Failed']).optional(), + reference: z.string().optional(), + notes: z.string().optional(), + customerId: AltovizIdSchema.optional(), + links: z.array(ReceiptLinkSchema).optional(), +}); +export type UpdateReceiptInput = z.infer; + +const GetReceiptInputSchema = z.object({ receiptId: AltovizIdSchema }); +export type GetReceiptInput = z.infer; + +const FindReceiptInputSchema = z.object({ internalId: z.string().optional() }); +export type FindReceiptInput = z.infer; + +const ListReceiptsInputSchema = z.object(PagingInputSchema); +export type ListReceiptsInput = z.infer; + +const DeleteReceiptInputSchema = z.object({ receiptId: AltovizIdSchema }); +export type DeleteReceiptInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// purchaseInvoices +// ───────────────────────────────────────────────────────────────────────────── + +/** The only multipart operation in the surface, and the only create with no delete anywhere in the API - an uploaded document can only be removed in the Altoviz UI. */ +const UploadPurchaseInvoiceInputSchema = z.object({ + fileBase64: z.string().min(1), + fileName: z.string().min(1), + mimeType: z.string().default('application/pdf'), +}); +export type UploadPurchaseInvoiceInput = z.infer< + typeof UploadPurchaseInvoiceInputSchema +>; + +const PurchaseInvoiceOutputSchema = z + .object({ + id: z.number(), + date: z.string().nullable().optional(), + reference: z.string().nullable().optional(), + subject: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + region: z.string().nullable().optional(), + status: z.string().nullable().optional(), + supplier: z.unknown().nullable().optional(), + taxIncludedAmount: z.number().nullable().optional(), + vatReverseCharge: z.boolean().nullable().optional(), + pdfUrl: z.string().nullable().optional(), + }) + .loose(); +export type PurchaseInvoiceOutput = z.infer; + +const DownloadPurchaseInvoiceInputSchema = z.object({ + purchaseInvoiceId: AltovizIdSchema, +}); +export type DownloadPurchaseInvoiceInput = z.infer< + typeof DownloadPurchaseInvoiceInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint input/output type maps +// ───────────────────────────────────────────────────────────────────────────── + +export type AltovizEndpointInputs = { + customersCreate: CreateCustomerInput; + customersUpdate: UpdateCustomerInput; + customersDelete: DeleteCustomerInput; + customersGet: GetCustomerInput; + customersGetByInternalId: GetCustomerByInternalIdInput; + customersFind: FindCustomerInput; + customersList: ListCustomersInput; + customersGetContacts: GetCustomerContactsInput; + + customerFamiliesCreate: CreateCustomerFamilyInput; + customerFamiliesGet: GetCustomerFamilyInput; + customerFamiliesDelete: DeleteCustomerFamilyInput; + customerFamiliesList: ListCustomerFamiliesInput; + + suppliersGet: GetSupplierInput; + suppliersList: ListSuppliersInput; + suppliersUpdate: UpdateSupplierInput; + suppliersDelete: DeleteSupplierInput; + suppliersGetContacts: GetSupplierContactsInput; + + contactsCreate: CreateContactInput; + contactsGet: GetContactInput; + contactsFind: FindContactInput; + contactsList: ListContactsInput; + + colleaguesGet: GetColleagueInput; + colleaguesList: ListColleaguesInput; + colleaguesUpdate: UpdateColleagueInput; + colleaguesDelete: DeleteColleagueInput; + + accountGetCurrentUser: EmptyInput; + accountTestApiKey: EmptyInput; + accountGetSettings: EmptyInput; + accountGetUnits: EmptyInput; + accountGetVats: EmptyInput; + accountGetClassifications: GetClassificationsInput; + + webhookSubscriptionsList: ListWebhooksInput; + webhookSubscriptionsRegister: RegisterWebhookInput; + webhookSubscriptionsUnregister: UnregisterWebhookInput; + + productsCreate: CreateProductInput; + productsDelete: DeleteProductInput; + productsGet: GetProductInput; + productsFind: FindProductInput; + productsFindByNumberOrId: FindProductByNumberOrIdInput; + + productFamiliesCreate: CreateProductFamilyInput; + productFamiliesGet: GetProductFamilyInput; + productFamiliesDelete: DeleteProductFamilyInput; + productFamiliesList: ListProductFamiliesInput; + + saleInvoicesCreate: CreateSaleInvoiceInput; + saleInvoicesGet: GetSaleInvoiceInput; + saleInvoicesFind: FindSaleInvoiceInput; + saleInvoicesList: ListSaleInvoicesInput; + saleInvoicesDelete: DeleteSaleInvoiceInput; + saleInvoicesDownload: DownloadSaleInvoiceInput; + + saleCreditsCreate: CreateSaleCreditInput; + saleCreditsUpdate: UpdateSaleCreditInput; + saleCreditsGet: GetSaleCreditInput; + saleCreditsFind: FindSaleCreditInput; + saleCreditsList: ListSaleCreditsInput; + saleCreditsDelete: DeleteSaleCreditInput; + saleCreditsDownload: DownloadSaleCreditInput; + + saleQuotesFind: FindSaleQuoteInput; + saleQuotesList: ListSaleQuotesInput; + saleQuotesDelete: DeleteSaleQuoteInput; + + receiptsCreate: CreateReceiptInput; + receiptsUpdate: UpdateReceiptInput; + receiptsGet: GetReceiptInput; + receiptsFind: FindReceiptInput; + receiptsList: ListReceiptsInput; + receiptsDelete: DeleteReceiptInput; + + purchaseInvoicesUpload: UploadPurchaseInvoiceInput; + purchaseInvoicesDownload: DownloadPurchaseInvoiceInput; +}; + +export type AltovizEndpointOutputs = { + customersCreate: CustomerOutput; + customersUpdate: CustomerOutput; + customersDelete: DeletedResult; + customersGet: CustomerOutput; + customersGetByInternalId: CustomerOutput; + customersFind: CustomerOutput[]; + customersList: CustomerOutput[]; + customersGetContacts: ContactOutput[]; + + customerFamiliesCreate: CustomerFamilyOutput; + customerFamiliesGet: CustomerFamilyOutput; + customerFamiliesDelete: DeletedResult; + customerFamiliesList: CustomerFamilyOutput[]; + + suppliersGet: SupplierOutput; + suppliersList: SupplierOutput[]; + suppliersUpdate: SupplierOutput; + suppliersDelete: DeletedResult; + suppliersGetContacts: ContactOutput[]; + + contactsCreate: ContactOutput; + contactsGet: ContactOutput; + contactsFind: ContactOutput[]; + contactsList: ContactOutput[]; + + colleaguesGet: ColleagueOutput; + colleaguesList: ColleagueOutput[]; + colleaguesUpdate: ColleagueOutput; + colleaguesDelete: DeletedResult; + + accountGetCurrentUser: CurrentUserOutput; + accountTestApiKey: HelloOutput; + accountGetSettings: SettingsOutput; + accountGetUnits: UnitOutput[]; + accountGetVats: VatOutput[]; + accountGetClassifications: ClassificationOutput[]; + + webhookSubscriptionsList: WebhookOutput[]; + webhookSubscriptionsRegister: RegisterWebhookOutput; + webhookSubscriptionsUnregister: UnregisterWebhookOutput; + + productsCreate: ProductOutput; + productsDelete: DeletedResult; + productsGet: ProductOutput; + productsFind: ProductOutput[]; + productsFindByNumberOrId: ProductOutput[]; + + productFamiliesCreate: ProductFamilyOutput; + productFamiliesGet: ProductFamilyOutput; + productFamiliesDelete: DeletedResult; + productFamiliesList: ProductFamilyOutput[]; + + saleInvoicesCreate: SaleInvoiceOutput; + saleInvoicesGet: SaleInvoiceOutput; + saleInvoicesFind: SaleInvoiceOutput[]; + saleInvoicesList: SaleInvoiceOutput[]; + saleInvoicesDelete: DeletedResult; + saleInvoicesDownload: DownloadOutput; + + saleCreditsCreate: SaleCreditOutput; + saleCreditsUpdate: SaleCreditOutput; + saleCreditsGet: SaleCreditOutput; + saleCreditsFind: SaleCreditOutput[]; + saleCreditsList: SaleCreditOutput[]; + saleCreditsDelete: DeletedResult; + saleCreditsDownload: DownloadOutput; + + saleQuotesFind: SaleQuoteOutput[]; + saleQuotesList: SaleQuoteOutput[]; + saleQuotesDelete: DeletedResult; + + receiptsCreate: ReceiptOutput; + receiptsUpdate: ReceiptOutput; + receiptsGet: ReceiptOutput; + receiptsFind: ReceiptOutput[]; + receiptsList: ReceiptOutput[]; + receiptsDelete: DeletedResult; + + purchaseInvoicesUpload: PurchaseInvoiceOutput; + purchaseInvoicesDownload: DownloadOutput; +}; + +export const AltovizEndpointInputSchemas = { + customersCreate: CreateCustomerInputSchema, + customersUpdate: UpdateCustomerInputSchema, + customersDelete: DeleteCustomerInputSchema, + customersGet: GetCustomerInputSchema, + customersGetByInternalId: GetCustomerByInternalIdInputSchema, + customersFind: FindCustomerInputSchema, + customersList: ListCustomersInputSchema, + customersGetContacts: GetCustomerContactsInputSchema, + + customerFamiliesCreate: CreateCustomerFamilyInputSchema, + customerFamiliesGet: GetCustomerFamilyInputSchema, + customerFamiliesDelete: DeleteCustomerFamilyInputSchema, + customerFamiliesList: ListCustomerFamiliesInputSchema, + + suppliersGet: GetSupplierInputSchema, + suppliersList: ListSuppliersInputSchema, + suppliersUpdate: UpdateSupplierInputSchema, + suppliersDelete: DeleteSupplierInputSchema, + suppliersGetContacts: GetSupplierContactsInputSchema, + + contactsCreate: CreateContactInputSchema, + contactsGet: GetContactInputSchema, + contactsFind: FindContactInputSchema, + contactsList: ListContactsInputSchema, + + colleaguesGet: GetColleagueInputSchema, + colleaguesList: ListColleaguesInputSchema, + colleaguesUpdate: UpdateColleagueInputSchema, + colleaguesDelete: DeleteColleagueInputSchema, + + accountGetCurrentUser: EmptyInputSchema, + accountTestApiKey: EmptyInputSchema, + accountGetSettings: EmptyInputSchema, + accountGetUnits: EmptyInputSchema, + accountGetVats: EmptyInputSchema, + accountGetClassifications: GetClassificationsInputSchema, + + webhookSubscriptionsList: ListWebhooksInputSchema, + webhookSubscriptionsRegister: RegisterWebhookInputSchema, + webhookSubscriptionsUnregister: UnregisterWebhookInputSchema, + + productsCreate: CreateProductInputSchema, + productsDelete: DeleteProductInputSchema, + productsGet: GetProductInputSchema, + productsFind: FindProductInputSchema, + productsFindByNumberOrId: FindProductByNumberOrIdInputSchema, + + productFamiliesCreate: CreateProductFamilyInputSchema, + productFamiliesGet: GetProductFamilyInputSchema, + productFamiliesDelete: DeleteProductFamilyInputSchema, + productFamiliesList: ListProductFamiliesInputSchema, + + saleInvoicesCreate: CreateSaleInvoiceInputSchema, + saleInvoicesGet: GetSaleInvoiceInputSchema, + saleInvoicesFind: FindSaleInvoiceInputSchema, + saleInvoicesList: ListSaleInvoicesInputSchema, + saleInvoicesDelete: DeleteSaleInvoiceInputSchema, + saleInvoicesDownload: DownloadSaleInvoiceInputSchema, + + saleCreditsCreate: CreateSaleCreditInputSchema, + saleCreditsUpdate: UpdateSaleCreditInputSchema, + saleCreditsGet: GetSaleCreditInputSchema, + saleCreditsFind: FindSaleCreditInputSchema, + saleCreditsList: ListSaleCreditsInputSchema, + saleCreditsDelete: DeleteSaleCreditInputSchema, + saleCreditsDownload: DownloadSaleCreditInputSchema, + + saleQuotesFind: FindSaleQuoteInputSchema, + saleQuotesList: ListSaleQuotesInputSchema, + saleQuotesDelete: DeleteSaleQuoteInputSchema, + + receiptsCreate: CreateReceiptInputSchema, + receiptsUpdate: UpdateReceiptInputSchema, + receiptsGet: GetReceiptInputSchema, + receiptsFind: FindReceiptInputSchema, + receiptsList: ListReceiptsInputSchema, + receiptsDelete: DeleteReceiptInputSchema, + + purchaseInvoicesUpload: UploadPurchaseInvoiceInputSchema, + purchaseInvoicesDownload: DownloadPurchaseInvoiceInputSchema, +} as const; + +export const AltovizEndpointOutputSchemas = { + customersCreate: CustomerOutputSchema, + customersUpdate: CustomerOutputSchema, + customersDelete: DeletedResultSchema, + customersGet: CustomerOutputSchema, + customersGetByInternalId: CustomerOutputSchema, + customersFind: z.array(CustomerOutputSchema), + customersList: z.array(CustomerOutputSchema), + customersGetContacts: z.array(ContactOutputSchema), + + customerFamiliesCreate: CustomerFamilyOutputSchema, + customerFamiliesGet: CustomerFamilyOutputSchema, + customerFamiliesDelete: DeletedResultSchema, + customerFamiliesList: z.array(CustomerFamilyOutputSchema), + + suppliersGet: SupplierOutputSchema, + suppliersList: z.array(SupplierOutputSchema), + suppliersUpdate: SupplierOutputSchema, + suppliersDelete: DeletedResultSchema, + suppliersGetContacts: z.array(ContactOutputSchema), + + contactsCreate: ContactOutputSchema, + contactsGet: ContactOutputSchema, + contactsFind: z.array(ContactOutputSchema), + contactsList: z.array(ContactOutputSchema), + + colleaguesGet: ColleagueOutputSchema, + colleaguesList: z.array(ColleagueOutputSchema), + colleaguesUpdate: ColleagueOutputSchema, + colleaguesDelete: DeletedResultSchema, + + accountGetCurrentUser: CurrentUserOutputSchema, + accountTestApiKey: HelloOutputSchema, + accountGetSettings: SettingsOutputSchema, + accountGetUnits: z.array(UnitOutputSchema), + accountGetVats: z.array(VatOutputSchema), + accountGetClassifications: z.array(ClassificationOutputSchema), + + webhookSubscriptionsList: z.array(WebhookOutputSchema), + webhookSubscriptionsRegister: RegisterWebhookOutputSchema, + webhookSubscriptionsUnregister: UnregisterWebhookOutputSchema, + + productsCreate: ProductOutputSchema, + productsDelete: DeletedResultSchema, + productsGet: ProductOutputSchema, + productsFind: z.array(ProductOutputSchema), + productsFindByNumberOrId: z.array(ProductOutputSchema), + + productFamiliesCreate: ProductFamilyOutputSchema, + productFamiliesGet: ProductFamilyOutputSchema, + productFamiliesDelete: DeletedResultSchema, + productFamiliesList: z.array(ProductFamilyOutputSchema), + + saleInvoicesCreate: SaleInvoiceOutputSchema, + saleInvoicesGet: SaleInvoiceOutputSchema, + saleInvoicesFind: z.array(SaleInvoiceOutputSchema), + saleInvoicesList: z.array(SaleInvoiceOutputSchema), + saleInvoicesDelete: DeletedResultSchema, + saleInvoicesDownload: DownloadOutputSchema, + + saleCreditsCreate: SaleCreditOutputSchema, + saleCreditsUpdate: SaleCreditOutputSchema, + saleCreditsGet: SaleCreditOutputSchema, + saleCreditsFind: z.array(SaleCreditOutputSchema), + saleCreditsList: z.array(SaleCreditOutputSchema), + saleCreditsDelete: DeletedResultSchema, + saleCreditsDownload: DownloadOutputSchema, + + saleQuotesFind: z.array(SaleQuoteOutputSchema), + saleQuotesList: z.array(SaleQuoteOutputSchema), + saleQuotesDelete: DeletedResultSchema, + + receiptsCreate: ReceiptOutputSchema, + receiptsUpdate: ReceiptOutputSchema, + receiptsGet: ReceiptOutputSchema, + receiptsFind: z.array(ReceiptOutputSchema), + receiptsList: z.array(ReceiptOutputSchema), + receiptsDelete: DeletedResultSchema, + + purchaseInvoicesUpload: PurchaseInvoiceOutputSchema, + purchaseInvoicesDownload: DownloadOutputSchema, +} as const; diff --git a/packages/altoviz/endpoints/webhook-subscriptions.ts b/packages/altoviz/endpoints/webhook-subscriptions.ts new file mode 100644 index 000000000..54ef94588 --- /dev/null +++ b/packages/altoviz/endpoints/webhook-subscriptions.ts @@ -0,0 +1,83 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAltovizRequest } from '../client'; +import type { AltovizEndpoints } from '../index'; +import { auditPayload } from './logging'; +import { compactBody } from './shared'; +import type { AltovizEndpointOutputs } from './types'; + +export const list: AltovizEndpoints['webhookSubscriptions']['list'] = async ( + ctx, +) => { + const result = await makeAltovizRequest< + AltovizEndpointOutputs['webhookSubscriptionsList'] + >('v1/webhooks', ctx.key); + + await logEventFromContext( + ctx, + 'altoviz.webhookSubscriptions.list', + {}, + 'completed', + ); + return result; +}; + +/** + * The only 201 in the surface, and the response body carries `id: 0` - + * confirmed live. The real id only appears in `LIST_WEBHOOKS`, and that list + * is eventually consistent (a deleted webhook briefly reappeared for one call + * about two seconds after its delete during recon), so this returns exactly + * what the provider sent rather than inventing a usable id. A caller that + * needs the real id should list immediately after registering. + */ +export const register: AltovizEndpoints['webhookSubscriptions']['register'] = + async (ctx, input) => { + const body = compactBody({ + name: input.name, + url: input.url, + types: input.types, + secretKey: input.secretKey, + }); + + const result = await makeAltovizRequest< + AltovizEndpointOutputs['webhookSubscriptionsRegister'] + >('v1/webhooks', ctx.key, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'altoviz.webhookSubscriptions.register', + auditPayload(input, { typesCount: input.types.length }), + 'completed', + ); + return result; + }; + +/** + * `id` and `url` are both optional in the provider's own spec, so a call + * supplying neither could plausibly delete broadly. That shape was + * deliberately never probed live - the input schema + * (`UnregisterWebhookInputSchema`) already refuses a call with neither before + * this handler is reached, so the guard is enforced twice: once by the + * schema, once implicitly by never constructing a bodyless query here. + */ +export const unregister: AltovizEndpoints['webhookSubscriptions']['unregister'] = + async (ctx, input) => { + const query = + input.webhookId !== undefined + ? { id: input.webhookId } + : { url: input.url }; + + await makeAltovizRequest('v1/webhooks', ctx.key, { + method: 'DELETE', + query, + }); + + await logEventFromContext( + ctx, + 'altoviz.webhookSubscriptions.unregister', + auditPayload(input), + 'completed', + ); + return input.webhookId !== undefined + ? { deleted: true, id: input.webhookId } + : { deleted: true, url: input.url }; + }; diff --git a/packages/altoviz/error-handlers.test.ts b/packages/altoviz/error-handlers.test.ts new file mode 100644 index 000000000..98fc8bf73 --- /dev/null +++ b/packages/altoviz/error-handlers.test.ts @@ -0,0 +1,172 @@ +/** + * Every status this API answers with, mapped to the retry decision this + * plugin makes for it - including the four empty-body statuses (401, 404 + * unknown-route, 405, 429) and the 409 conflict. Bind retries stay at 0 + * because corsair/core discards a successful retry and rethrows. + */ +import { ApiError } from 'corsair/http'; +import { errorHandlers, isNonIdempotent } from './error-handlers'; + +function apiError(status: number, body?: unknown): ApiError { + const err = new ApiError( + { method: 'GET', url: 'https://api.altoviz.com/v1/customers/1' }, + { + url: 'https://api.altoviz.com/v1/customers/1', + ok: false, + status, + statusText: 'Error', + body, + }, + 'error', + ); + return err; +} + +function context(operation: string, error: Error) { + return { pluginId: 'altoviz', operation, input: {}, originalError: error }; +} + +describe('status-to-handler mapping', () => { + test('401 (empty body) matches AUTH_ERROR and is never retried', () => { + const error = apiError(401, undefined); + expect(errorHandlers.AUTH_ERROR.match(error)).toBe(true); + }); + + test('404 matches NOT_FOUND_ERROR whether the body is a message or empty', async () => { + const withMessage = apiError(404, { + errors: [], + message: 'Customer with ID 1 not found.', + }); + const empty = apiError(404, undefined); + expect(errorHandlers.NOT_FOUND_ERROR.match(withMessage)).toBe(true); + expect(errorHandlers.NOT_FOUND_ERROR.match(empty)).toBe(true); + const result = await errorHandlers.NOT_FOUND_ERROR.handler( + withMessage, + context('customers.get', withMessage), + ); + expect(result.maxRetries).toBe(0); + }); + + test('405 matches METHOD_ERROR', () => { + const error = apiError(405, undefined); + expect(errorHandlers.METHOD_ERROR.match(error)).toBe(true); + }); + + test('409 matches CONFLICT_ERROR and is never retried', async () => { + const error = apiError(409, { + errors: null, + message: "L'element ... ne peut pas etre supprime car il a ete utilise.", + }); + expect(errorHandlers.CONFLICT_ERROR.match(error)).toBe(true); + const result = await errorHandlers.CONFLICT_ERROR.handler( + error, + context('customerFamilies.delete', error), + ); + expect(result.maxRetries).toBe(0); + }); + + test('400 matches VALIDATION_ERROR whether errors is an array, empty, or null', () => { + for (const body of [ + { errors: ['bad'], message: 'Validation failed' }, + { errors: [], message: 'Number or internal ID have to be defined' }, + { errors: null, message: "La TVA n'existe pas." }, + ]) { + const error = apiError(400, body); + expect(errorHandlers.VALIDATION_ERROR.match(error)).toBe(true); + } + }); + + test('429 matches RATE_LIMIT_ERROR and honours Retry-After in milliseconds', async () => { + const error = new ApiError( + { method: 'GET', url: 'https://api.altoviz.com/v1/units' }, + { + url: 'https://api.altoviz.com/v1/units', + ok: false, + status: 429, + statusText: 'Error', + body: undefined, + }, + 'error', + { retryAfter: 36000 * 1000 }, + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + error, + context('account.getUnits', error), + ); + expect(result.headersRetryAfterMs).toBe(36000); + expect(result.maxRetries).toBe(0); + }); + + test('429 on a non-idempotent operation is never retried, even with Retry-After present', async () => { + const error = new ApiError( + { method: 'POST', url: 'https://api.altoviz.com/v1/saleinvoices' }, + { + url: 'https://api.altoviz.com/v1/saleinvoices', + ok: false, + status: 429, + statusText: 'Error', + body: undefined, + }, + 'error', + { retryAfter: 13000 }, + ); + const result = await errorHandlers.RATE_LIMIT_ERROR.handler( + error, + context('saleInvoices.create', error), + ); + expect(result.maxRetries).toBe(0); + }); + + test('500 is never retried by bind', async () => { + const error = apiError(500, { + errors: ['Une erreur est survenue.'], + message: 'Internal error', + }); + const readResult = await errorHandlers.SERVER_ERROR.handler( + error, + context('customers.get', error), + ); + expect(readResult.maxRetries).toBe(0); + const writeResult = await errorHandlers.SERVER_ERROR.handler( + error, + context('saleInvoices.create', error), + ); + expect(writeResult.maxRetries).toBe(0); + }); + + test('a network error is never retried by bind', async () => { + const error = new Error('fetch failed'); + expect(errorHandlers.NETWORK_ERROR.match(error)).toBe(true); + const readResult = await errorHandlers.NETWORK_ERROR.handler( + error, + context('customers.get', error), + ); + expect(readResult.maxRetries).toBe(0); + const writeResult = await errorHandlers.NETWORK_ERROR.handler( + error, + context('customers.create', error), + ); + expect(writeResult.maxRetries).toBe(0); + expect(errorHandlers.NETWORK_ERROR.match(apiError(400))).toBe(false); + }); + + test('DEFAULT catches everything else and never retries', async () => { + const error = new Error('something unexpected'); + expect(errorHandlers.DEFAULT.match()).toBe(true); + const result = await errorHandlers.DEFAULT.handler( + error, + context('customers.get', error), + ); + expect(result.maxRetries).toBe(0); + }); +}); + +describe('isNonIdempotent', () => { + test('every write and destructive operation is non-idempotent; every read is not', () => { + expect(isNonIdempotent('customers.create')).toBe(true); + expect(isNonIdempotent('customers.delete')).toBe(true); + expect(isNonIdempotent('customers.get')).toBe(false); + expect(isNonIdempotent('customers.list')).toBe(false); + }); +}); diff --git a/packages/altoviz/error-handlers.ts b/packages/altoviz/error-handlers.ts new file mode 100644 index 000000000..4ea82d38e --- /dev/null +++ b/packages/altoviz/error-handlers.ts @@ -0,0 +1,223 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Whether replaying an operation could duplicate or corrupt data. + * + * Corsair re-invokes the whole endpoint when a handler asks for a retry. Every + * write here is a POST that creates a document or record (a replayed + * `saleinvoices.create` is a duplicate invoice, not a retry) or a PUT that - + * because Altoviz's PUT clears every field the body omits - is not safe to + * blind-replay if the first attempt's response was lost. Every destructive + * operation deletes something the provider will not undo. + * + * Listed explicitly per operation path rather than by name pattern, so a + * future operation is not silently included or excluded. `endpoints.test.ts` + * asserts this set is exactly the set of non-read operations in the registry. + */ +export const NON_IDEMPOTENT_OPERATIONS = new Set([ + 'customers.create', + 'customers.update', + 'customers.delete', + 'customerFamilies.create', + 'customerFamilies.delete', + 'suppliers.update', + 'suppliers.delete', + 'contacts.create', + 'colleagues.update', + 'colleagues.delete', + 'webhookSubscriptions.register', + 'webhookSubscriptions.unregister', + 'products.create', + 'products.delete', + 'productFamilies.create', + 'productFamilies.delete', + 'saleInvoices.create', + 'saleInvoices.delete', + 'saleCredits.create', + 'saleCredits.update', + 'saleCredits.delete', + 'saleQuotes.delete', + 'receipts.create', + 'receipts.update', + 'receipts.delete', + 'purchaseInvoices.upload', +]); + +export const isNonIdempotent = (operation: string): boolean => + NON_IDEMPOTENT_OPERATIONS.has(operation); + +function providerMessage(error: Error, fallback: string): string { + const body = error instanceof ApiError ? error.body : undefined; + if ( + body && + typeof body === 'object' && + 'message' in body && + (body as { message?: unknown }).message != null + ) { + return String((body as { message: unknown }).message); + } + return fallback; +} + +/** + * Altoviz's error surface, captured live rather than from documentation: + * + * 400 three shapes - {errors:[...],message:"Validation failed"}, + * {errors:[],message:""}, {errors:null,message:""} + * 401 a missing or invalid key - EMPTY body, zero bytes, no content-type + * 404 a known route with an absent record (message), OR an unknown route + * (empty body), OR RFC 9110 ProblemDetails on one route family + * 405 wrong method on a real route - empty body + * 409 a delete refused because the record is still in use - French message + * 429 quota exhausted - plain text, no content-type, with Retry-After + * 500 two shapes - {errors:[...],message:"Internal error"} and + * {errors:[],message:"An error occured"} (the provider's spelling) + * + * `corsair/async-core`'s `getResponseBody` only parses a body when a + * `Content-Type` header is present, so the four empty-body statuses (401, 404 + * unknown-route, 405, 429) all arrive here with `error.body === undefined`. + * Handlers below match on `error.status` for that reason rather than reading + * the body, and supply their own message where the provider supplies none. + */ +export const errorHandlers = { + /** + * Measured live: the quota is exactly 100 requests over a rolling window. + * Bind retries are never requested: corsair/core awaits a successful retry + * then still throws the original error. GET 429s retry in the client. + */ + RATE_LIMIT_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 429) return true; + return error.message.toLowerCase().includes('too many requests'); + }, + handler: async (error, context) => { + // corsair/http stored header*1000 assuming seconds; Altoviz sent ms. + const retryAfterMs = + error instanceof ApiError && error.retryAfter != null + ? error.retryAfter / 1000 + : undefined; + return { + maxRetries: 0, + headersRetryAfterMs: retryAfterMs, + }; + }, + }, + /** + * A missing or invalid X-API-KEY answers 401 with a completely empty body - + * confirmed for a blank header and for a well-formed key that does not + * exist. There is no body to read, so this is matched on status alone. + */ + AUTH_ERROR: { + match: (error) => error instanceof ApiError && error.status === 401, + handler: async (error, context) => { + console.warn( + `[ALTOVIZ:${context.operation}] Authentication failed - the X-API-KEY header is missing or invalid`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * A delete refused because the record is still referenced - a customer + * family or product family that still has members. Not a transient + * failure: retrying without first removing the members fails identically. + */ + CONFLICT_ERROR: { + match: (error) => error instanceof ApiError && error.status === 409, + handler: async (error, context) => { + const status = error instanceof ApiError ? error.status : undefined; + console.warn( + `[ALTOVIZ:${context.operation}] ${status} Conflict: ${providerMessage(error, error.message)}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * A known route with an absent record, or an unknown route entirely (empty + * body). Either way, retrying the same request cannot succeed. + */ + NOT_FOUND_ERROR: { + match: (error) => error instanceof ApiError && error.status === 404, + handler: async (error, context) => { + const status = error instanceof ApiError ? error.status : undefined; + console.warn( + `[ALTOVIZ:${context.operation}] ${status}: ${providerMessage(error, 'not found')}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * Wrong HTTP method on a real route - a plugin bug, not a transient state. + */ + METHOD_ERROR: { + match: (error) => error instanceof ApiError && error.status === 405, + handler: async (error, context) => { + console.warn( + `[ALTOVIZ:${context.operation}] Method not allowed on this route`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * Validation failures, including the numbering-sequence precondition + * ("La numerotation des ... n'a pas ete initialisee") and the nested + * reference-by-id rejection ("La TVA n'existe pas."). Message language is + * inconsistent - English for structural validation, French for business + * rules - so neither is matched on text, only on status. + */ + VALIDATION_ERROR: { + match: (error) => error instanceof ApiError && error.status === 400, + handler: async (error, context) => { + const status = error instanceof ApiError ? error.status : undefined; + console.warn( + `[ALTOVIZ:${context.operation}] ${status} Invalid request: ${providerMessage(error, error.message)}`, + ); + return { maxRetries: 0 }; + }, + }, + /** + * A server fault. Bind retries are never requested: corsair/core awaits a + * successful retry then still throws the original error. + */ + SERVER_ERROR: { + match: (error) => + error instanceof ApiError && + error.status !== undefined && + error.status >= 500, + handler: async (error, context) => { + const status = error instanceof ApiError ? error.status : 'unknown'; + console.warn( + `[ALTOVIZ:${context.operation}] ${status}: ${providerMessage(error, error.message)}`, + ); + return { maxRetries: 0 }; + }, + }, + NETWORK_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status !== undefined) return false; + const message = error.message.toLowerCase(); + return ( + message.includes('network') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('etimedout') || + message.includes('fetch failed') + ); + }, + handler: async (error, context) => { + console.warn( + `[ALTOVIZ:${context.operation}] Network error: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, + DEFAULT: { + match: () => true, + handler: async (error, context) => { + console.error( + `[ALTOVIZ:${context.operation}] Unhandled error: ${error.message}`, + ); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/altoviz/index.ts b/packages/altoviz/index.ts new file mode 100644 index 000000000..1e916dbec --- /dev/null +++ b/packages/altoviz/index.ts @@ -0,0 +1,851 @@ +import type { + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Account, + Colleagues, + Contacts, + CustomerFamilies, + Customers, + ProductFamilies, + Products, + PurchaseInvoices, + Receipts, + SaleCredits, + SaleInvoices, + SaleQuotes, + Suppliers, + WebhookSubscriptions, +} from './endpoints'; +import type { + AltovizEndpointInputs, + AltovizEndpointOutputs, +} from './endpoints/types'; +import { + AltovizEndpointInputSchemas, + AltovizEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AltovizSchema } from './schema'; + +export type AltovizPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalAltovizPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AltovizContext = CorsairPluginContext< + typeof AltovizSchema, + AltovizPluginOptions +>; + +export type AltovizKeyBuilderContext = KeyBuilderContext; + +export type AltovizBoundEndpoints = BindEndpoints< + typeof altovizEndpointsNested +>; + +type AltovizEndpoint = CorsairEndpoint< + AltovizContext, + AltovizEndpointInputs[K], + AltovizEndpointOutputs[K] +>; + +export type AltovizEndpoints = { + customers: { + create: AltovizEndpoint<'customersCreate'>; + update: AltovizEndpoint<'customersUpdate'>; + delete: AltovizEndpoint<'customersDelete'>; + get: AltovizEndpoint<'customersGet'>; + getByInternalId: AltovizEndpoint<'customersGetByInternalId'>; + find: AltovizEndpoint<'customersFind'>; + list: AltovizEndpoint<'customersList'>; + getContacts: AltovizEndpoint<'customersGetContacts'>; + }; + customerFamilies: { + create: AltovizEndpoint<'customerFamiliesCreate'>; + get: AltovizEndpoint<'customerFamiliesGet'>; + delete: AltovizEndpoint<'customerFamiliesDelete'>; + list: AltovizEndpoint<'customerFamiliesList'>; + }; + suppliers: { + get: AltovizEndpoint<'suppliersGet'>; + list: AltovizEndpoint<'suppliersList'>; + update: AltovizEndpoint<'suppliersUpdate'>; + delete: AltovizEndpoint<'suppliersDelete'>; + getContacts: AltovizEndpoint<'suppliersGetContacts'>; + }; + contacts: { + create: AltovizEndpoint<'contactsCreate'>; + get: AltovizEndpoint<'contactsGet'>; + find: AltovizEndpoint<'contactsFind'>; + list: AltovizEndpoint<'contactsList'>; + }; + colleagues: { + get: AltovizEndpoint<'colleaguesGet'>; + list: AltovizEndpoint<'colleaguesList'>; + update: AltovizEndpoint<'colleaguesUpdate'>; + delete: AltovizEndpoint<'colleaguesDelete'>; + }; + account: { + getCurrentUser: AltovizEndpoint<'accountGetCurrentUser'>; + testApiKey: AltovizEndpoint<'accountTestApiKey'>; + getSettings: AltovizEndpoint<'accountGetSettings'>; + getUnits: AltovizEndpoint<'accountGetUnits'>; + getVats: AltovizEndpoint<'accountGetVats'>; + getClassifications: AltovizEndpoint<'accountGetClassifications'>; + }; + webhookSubscriptions: { + list: AltovizEndpoint<'webhookSubscriptionsList'>; + register: AltovizEndpoint<'webhookSubscriptionsRegister'>; + unregister: AltovizEndpoint<'webhookSubscriptionsUnregister'>; + }; + products: { + create: AltovizEndpoint<'productsCreate'>; + delete: AltovizEndpoint<'productsDelete'>; + get: AltovizEndpoint<'productsGet'>; + find: AltovizEndpoint<'productsFind'>; + findByNumberOrId: AltovizEndpoint<'productsFindByNumberOrId'>; + }; + productFamilies: { + create: AltovizEndpoint<'productFamiliesCreate'>; + get: AltovizEndpoint<'productFamiliesGet'>; + delete: AltovizEndpoint<'productFamiliesDelete'>; + list: AltovizEndpoint<'productFamiliesList'>; + }; + saleInvoices: { + create: AltovizEndpoint<'saleInvoicesCreate'>; + get: AltovizEndpoint<'saleInvoicesGet'>; + find: AltovizEndpoint<'saleInvoicesFind'>; + list: AltovizEndpoint<'saleInvoicesList'>; + delete: AltovizEndpoint<'saleInvoicesDelete'>; + download: AltovizEndpoint<'saleInvoicesDownload'>; + }; + saleCredits: { + create: AltovizEndpoint<'saleCreditsCreate'>; + update: AltovizEndpoint<'saleCreditsUpdate'>; + get: AltovizEndpoint<'saleCreditsGet'>; + find: AltovizEndpoint<'saleCreditsFind'>; + list: AltovizEndpoint<'saleCreditsList'>; + delete: AltovizEndpoint<'saleCreditsDelete'>; + download: AltovizEndpoint<'saleCreditsDownload'>; + }; + saleQuotes: { + find: AltovizEndpoint<'saleQuotesFind'>; + list: AltovizEndpoint<'saleQuotesList'>; + delete: AltovizEndpoint<'saleQuotesDelete'>; + }; + receipts: { + create: AltovizEndpoint<'receiptsCreate'>; + update: AltovizEndpoint<'receiptsUpdate'>; + get: AltovizEndpoint<'receiptsGet'>; + find: AltovizEndpoint<'receiptsFind'>; + list: AltovizEndpoint<'receiptsList'>; + delete: AltovizEndpoint<'receiptsDelete'>; + }; + purchaseInvoices: { + upload: AltovizEndpoint<'purchaseInvoicesUpload'>; + download: AltovizEndpoint<'purchaseInvoicesDownload'>; + }; +}; + +const altovizEndpointsNested = { + customers: Customers, + customerFamilies: CustomerFamilies, + suppliers: Suppliers, + contacts: Contacts, + colleagues: Colleagues, + account: Account, + webhookSubscriptions: WebhookSubscriptions, + products: Products, + productFamilies: ProductFamilies, + saleInvoices: SaleInvoices, + saleCredits: SaleCredits, + saleQuotes: SaleQuotes, + receipts: Receipts, + purchaseInvoices: PurchaseInvoices, +} as const; + +export const altovizEndpointSchemas = { + 'customers.create': { + input: AltovizEndpointInputSchemas.customersCreate, + output: AltovizEndpointOutputSchemas.customersCreate, + }, + 'customers.update': { + input: AltovizEndpointInputSchemas.customersUpdate, + output: AltovizEndpointOutputSchemas.customersUpdate, + }, + 'customers.delete': { + input: AltovizEndpointInputSchemas.customersDelete, + output: AltovizEndpointOutputSchemas.customersDelete, + }, + 'customers.get': { + input: AltovizEndpointInputSchemas.customersGet, + output: AltovizEndpointOutputSchemas.customersGet, + }, + 'customers.getByInternalId': { + input: AltovizEndpointInputSchemas.customersGetByInternalId, + output: AltovizEndpointOutputSchemas.customersGetByInternalId, + }, + 'customers.find': { + input: AltovizEndpointInputSchemas.customersFind, + output: AltovizEndpointOutputSchemas.customersFind, + }, + 'customers.list': { + input: AltovizEndpointInputSchemas.customersList, + output: AltovizEndpointOutputSchemas.customersList, + }, + 'customers.getContacts': { + input: AltovizEndpointInputSchemas.customersGetContacts, + output: AltovizEndpointOutputSchemas.customersGetContacts, + }, + + 'customerFamilies.create': { + input: AltovizEndpointInputSchemas.customerFamiliesCreate, + output: AltovizEndpointOutputSchemas.customerFamiliesCreate, + }, + 'customerFamilies.get': { + input: AltovizEndpointInputSchemas.customerFamiliesGet, + output: AltovizEndpointOutputSchemas.customerFamiliesGet, + }, + 'customerFamilies.delete': { + input: AltovizEndpointInputSchemas.customerFamiliesDelete, + output: AltovizEndpointOutputSchemas.customerFamiliesDelete, + }, + 'customerFamilies.list': { + input: AltovizEndpointInputSchemas.customerFamiliesList, + output: AltovizEndpointOutputSchemas.customerFamiliesList, + }, + + 'suppliers.get': { + input: AltovizEndpointInputSchemas.suppliersGet, + output: AltovizEndpointOutputSchemas.suppliersGet, + }, + 'suppliers.list': { + input: AltovizEndpointInputSchemas.suppliersList, + output: AltovizEndpointOutputSchemas.suppliersList, + }, + 'suppliers.update': { + input: AltovizEndpointInputSchemas.suppliersUpdate, + output: AltovizEndpointOutputSchemas.suppliersUpdate, + }, + 'suppliers.delete': { + input: AltovizEndpointInputSchemas.suppliersDelete, + output: AltovizEndpointOutputSchemas.suppliersDelete, + }, + 'suppliers.getContacts': { + input: AltovizEndpointInputSchemas.suppliersGetContacts, + output: AltovizEndpointOutputSchemas.suppliersGetContacts, + }, + + 'contacts.create': { + input: AltovizEndpointInputSchemas.contactsCreate, + output: AltovizEndpointOutputSchemas.contactsCreate, + }, + 'contacts.get': { + input: AltovizEndpointInputSchemas.contactsGet, + output: AltovizEndpointOutputSchemas.contactsGet, + }, + 'contacts.find': { + input: AltovizEndpointInputSchemas.contactsFind, + output: AltovizEndpointOutputSchemas.contactsFind, + }, + 'contacts.list': { + input: AltovizEndpointInputSchemas.contactsList, + output: AltovizEndpointOutputSchemas.contactsList, + }, + + 'colleagues.get': { + input: AltovizEndpointInputSchemas.colleaguesGet, + output: AltovizEndpointOutputSchemas.colleaguesGet, + }, + 'colleagues.list': { + input: AltovizEndpointInputSchemas.colleaguesList, + output: AltovizEndpointOutputSchemas.colleaguesList, + }, + 'colleagues.update': { + input: AltovizEndpointInputSchemas.colleaguesUpdate, + output: AltovizEndpointOutputSchemas.colleaguesUpdate, + }, + 'colleagues.delete': { + input: AltovizEndpointInputSchemas.colleaguesDelete, + output: AltovizEndpointOutputSchemas.colleaguesDelete, + }, + + 'account.getCurrentUser': { + input: AltovizEndpointInputSchemas.accountGetCurrentUser, + output: AltovizEndpointOutputSchemas.accountGetCurrentUser, + }, + 'account.testApiKey': { + input: AltovizEndpointInputSchemas.accountTestApiKey, + output: AltovizEndpointOutputSchemas.accountTestApiKey, + }, + 'account.getSettings': { + input: AltovizEndpointInputSchemas.accountGetSettings, + output: AltovizEndpointOutputSchemas.accountGetSettings, + }, + 'account.getUnits': { + input: AltovizEndpointInputSchemas.accountGetUnits, + output: AltovizEndpointOutputSchemas.accountGetUnits, + }, + 'account.getVats': { + input: AltovizEndpointInputSchemas.accountGetVats, + output: AltovizEndpointOutputSchemas.accountGetVats, + }, + 'account.getClassifications': { + input: AltovizEndpointInputSchemas.accountGetClassifications, + output: AltovizEndpointOutputSchemas.accountGetClassifications, + }, + + 'webhookSubscriptions.list': { + input: AltovizEndpointInputSchemas.webhookSubscriptionsList, + output: AltovizEndpointOutputSchemas.webhookSubscriptionsList, + }, + 'webhookSubscriptions.register': { + input: AltovizEndpointInputSchemas.webhookSubscriptionsRegister, + output: AltovizEndpointOutputSchemas.webhookSubscriptionsRegister, + }, + 'webhookSubscriptions.unregister': { + input: AltovizEndpointInputSchemas.webhookSubscriptionsUnregister, + output: AltovizEndpointOutputSchemas.webhookSubscriptionsUnregister, + }, + + 'products.create': { + input: AltovizEndpointInputSchemas.productsCreate, + output: AltovizEndpointOutputSchemas.productsCreate, + }, + 'products.delete': { + input: AltovizEndpointInputSchemas.productsDelete, + output: AltovizEndpointOutputSchemas.productsDelete, + }, + 'products.get': { + input: AltovizEndpointInputSchemas.productsGet, + output: AltovizEndpointOutputSchemas.productsGet, + }, + 'products.find': { + input: AltovizEndpointInputSchemas.productsFind, + output: AltovizEndpointOutputSchemas.productsFind, + }, + 'products.findByNumberOrId': { + input: AltovizEndpointInputSchemas.productsFindByNumberOrId, + output: AltovizEndpointOutputSchemas.productsFindByNumberOrId, + }, + + 'productFamilies.create': { + input: AltovizEndpointInputSchemas.productFamiliesCreate, + output: AltovizEndpointOutputSchemas.productFamiliesCreate, + }, + 'productFamilies.get': { + input: AltovizEndpointInputSchemas.productFamiliesGet, + output: AltovizEndpointOutputSchemas.productFamiliesGet, + }, + 'productFamilies.delete': { + input: AltovizEndpointInputSchemas.productFamiliesDelete, + output: AltovizEndpointOutputSchemas.productFamiliesDelete, + }, + 'productFamilies.list': { + input: AltovizEndpointInputSchemas.productFamiliesList, + output: AltovizEndpointOutputSchemas.productFamiliesList, + }, + + 'saleInvoices.create': { + input: AltovizEndpointInputSchemas.saleInvoicesCreate, + output: AltovizEndpointOutputSchemas.saleInvoicesCreate, + }, + 'saleInvoices.get': { + input: AltovizEndpointInputSchemas.saleInvoicesGet, + output: AltovizEndpointOutputSchemas.saleInvoicesGet, + }, + 'saleInvoices.find': { + input: AltovizEndpointInputSchemas.saleInvoicesFind, + output: AltovizEndpointOutputSchemas.saleInvoicesFind, + }, + 'saleInvoices.list': { + input: AltovizEndpointInputSchemas.saleInvoicesList, + output: AltovizEndpointOutputSchemas.saleInvoicesList, + }, + 'saleInvoices.delete': { + input: AltovizEndpointInputSchemas.saleInvoicesDelete, + output: AltovizEndpointOutputSchemas.saleInvoicesDelete, + }, + 'saleInvoices.download': { + input: AltovizEndpointInputSchemas.saleInvoicesDownload, + output: AltovizEndpointOutputSchemas.saleInvoicesDownload, + }, + + 'saleCredits.create': { + input: AltovizEndpointInputSchemas.saleCreditsCreate, + output: AltovizEndpointOutputSchemas.saleCreditsCreate, + }, + 'saleCredits.update': { + input: AltovizEndpointInputSchemas.saleCreditsUpdate, + output: AltovizEndpointOutputSchemas.saleCreditsUpdate, + }, + 'saleCredits.get': { + input: AltovizEndpointInputSchemas.saleCreditsGet, + output: AltovizEndpointOutputSchemas.saleCreditsGet, + }, + 'saleCredits.find': { + input: AltovizEndpointInputSchemas.saleCreditsFind, + output: AltovizEndpointOutputSchemas.saleCreditsFind, + }, + 'saleCredits.list': { + input: AltovizEndpointInputSchemas.saleCreditsList, + output: AltovizEndpointOutputSchemas.saleCreditsList, + }, + 'saleCredits.delete': { + input: AltovizEndpointInputSchemas.saleCreditsDelete, + output: AltovizEndpointOutputSchemas.saleCreditsDelete, + }, + 'saleCredits.download': { + input: AltovizEndpointInputSchemas.saleCreditsDownload, + output: AltovizEndpointOutputSchemas.saleCreditsDownload, + }, + + 'saleQuotes.find': { + input: AltovizEndpointInputSchemas.saleQuotesFind, + output: AltovizEndpointOutputSchemas.saleQuotesFind, + }, + 'saleQuotes.list': { + input: AltovizEndpointInputSchemas.saleQuotesList, + output: AltovizEndpointOutputSchemas.saleQuotesList, + }, + 'saleQuotes.delete': { + input: AltovizEndpointInputSchemas.saleQuotesDelete, + output: AltovizEndpointOutputSchemas.saleQuotesDelete, + }, + + 'receipts.create': { + input: AltovizEndpointInputSchemas.receiptsCreate, + output: AltovizEndpointOutputSchemas.receiptsCreate, + }, + 'receipts.update': { + input: AltovizEndpointInputSchemas.receiptsUpdate, + output: AltovizEndpointOutputSchemas.receiptsUpdate, + }, + 'receipts.get': { + input: AltovizEndpointInputSchemas.receiptsGet, + output: AltovizEndpointOutputSchemas.receiptsGet, + }, + 'receipts.find': { + input: AltovizEndpointInputSchemas.receiptsFind, + output: AltovizEndpointOutputSchemas.receiptsFind, + }, + 'receipts.list': { + input: AltovizEndpointInputSchemas.receiptsList, + output: AltovizEndpointOutputSchemas.receiptsList, + }, + 'receipts.delete': { + input: AltovizEndpointInputSchemas.receiptsDelete, + output: AltovizEndpointOutputSchemas.receiptsDelete, + }, + + 'purchaseInvoices.upload': { + input: AltovizEndpointInputSchemas.purchaseInvoicesUpload, + output: AltovizEndpointOutputSchemas.purchaseInvoicesUpload, + }, + 'purchaseInvoices.download': { + input: AltovizEndpointInputSchemas.purchaseInvoicesDownload, + output: AltovizEndpointOutputSchemas.purchaseInvoicesDownload, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof altovizEndpointsNested +>; + +const defaultAuthType = 'api_key' as const; + +export const altovizEndpointMeta = { + 'customers.create': { + riskLevel: 'write', + description: + 'Create a customer. type is Business | Consumer | Government - NOT the Company/Individual the catalog description documents.', + }, + 'customers.update': { + riskLevel: 'write', + description: + 'Update a customer. Read-modify-write internally, because Altoviz PUT clears any field the caller omits.', + }, + 'customers.delete': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Delete a customer [DESTRUCTIVE]. Evicts the auto-created contact from the mirror if one is cached.', + }, + 'customers.get': { riskLevel: 'read', description: 'Get a customer by id' }, + 'customers.getByInternalId': { + riskLevel: 'read', + description: 'Get a customer by the caller-supplied internalId', + }, + 'customers.find': { + riskLevel: 'read', + description: + 'Find customers by email, internalId or number - returns an array, possibly empty', + }, + 'customers.list': { + riskLevel: 'read', + description: 'List customers, paged (PageIndex is 1-based)', + }, + 'customers.getContacts': { + riskLevel: 'read', + description: + "List a customer's contacts, including the one auto-created when the customer was created", + }, + + 'customerFamilies.create': { + riskLevel: 'write', + description: 'Create a customer family (segment)', + }, + 'customerFamilies.get': { + riskLevel: 'read', + description: 'Get a customer family by id', + }, + 'customerFamilies.delete': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Delete a customer family [DESTRUCTIVE]. Refused with a conflict if it still has members - it does not cascade.', + }, + 'customerFamilies.list': { + riskLevel: 'read', + description: 'List customer families, paged', + }, + + 'suppliers.get': { riskLevel: 'read', description: 'Get a supplier by id' }, + 'suppliers.list': { riskLevel: 'read', description: 'List suppliers, paged' }, + 'suppliers.update': { + riskLevel: 'write', + description: + 'Update a supplier. Read-modify-write internally, same clearing-PUT behaviour as customers.', + }, + 'suppliers.delete': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Delete a supplier [DESTRUCTIVE]. Evicts the auto-created contact from the mirror if one is cached.', + }, + 'suppliers.getContacts': { + riskLevel: 'read', + description: "List a supplier's contacts", + }, + + 'contacts.create': { + riskLevel: 'write', + description: + 'Create a standalone contact. There is no customerId field on this route - it cannot be attached to a customer here.', + }, + 'contacts.get': { riskLevel: 'read', description: 'Get a contact by id' }, + 'contacts.find': { + riskLevel: 'read', + description: + 'Find contacts by email or internalId - returns an array, possibly empty', + }, + 'contacts.list': { + riskLevel: 'read', + description: + 'List contacts, paged. Includes shadow contacts auto-created by customer/supplier/colleague writes.', + }, + + 'colleagues.get': { riskLevel: 'read', description: 'Get a colleague by id' }, + 'colleagues.list': { + riskLevel: 'read', + description: 'List colleagues, paged', + }, + 'colleagues.update': { + riskLevel: 'write', + description: + 'Update a colleague. Read-modify-write internally - a partial body is a 500 on this route, not just a clearing PUT.', + }, + 'colleagues.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a colleague [DESTRUCTIVE]', + }, + + 'account.getCurrentUser': { + riskLevel: 'read', + description: 'Get the authenticated user', + }, + 'account.testApiKey': { + riskLevel: 'read', + description: + 'Verify the API key and get the account identity - takes no parameters', + }, + 'account.getSettings': { + riskLevel: 'read', + description: + 'Get accounting, company, emailing, sales, social and VAT settings', + }, + 'account.getUnits': { + riskLevel: 'read', + description: 'List measurement units - reference data, mirrored', + }, + 'account.getVats': { + riskLevel: 'read', + description: 'List VAT rates - reference data, mirrored', + }, + 'account.getClassifications': { + riskLevel: 'read', + description: + 'List accounting classifications, optionally filtered by type (Sale | Expense | Other)', + }, + + 'webhookSubscriptions.list': { + riskLevel: 'read', + description: 'List registered webhook subscriptions', + }, + 'webhookSubscriptions.register': { + riskLevel: 'write', + description: + 'Register a webhook subscription. The response id is 0 - list immediately after to get the real id.', + }, + 'webhookSubscriptions.unregister': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Unregister a webhook subscription by id or url [DESTRUCTIVE]. Exactly one of the two is required.', + }, + + 'products.create': { + riskLevel: 'write', + description: + 'Create a product. unit/vat/family are resolved from an id to their value form before the request is sent.', + }, + 'products.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a product [DESTRUCTIVE]', + }, + 'products.get': { riskLevel: 'read', description: 'Get a product by id' }, + 'products.find': { + riskLevel: 'read', + description: 'Find a product by number - returns an array', + }, + 'products.findByNumberOrId': { + riskLevel: 'read', + description: + 'Find a product by number or internalId - same route as products.find, superset of parameters', + }, + + 'productFamilies.create': { + riskLevel: 'write', + description: 'Create a product family', + }, + 'productFamilies.get': { + riskLevel: 'read', + description: 'Get a product family by id', + }, + 'productFamilies.delete': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Delete a product family [DESTRUCTIVE]. Refused with a conflict if it still has members.', + }, + 'productFamilies.list': { + riskLevel: 'read', + description: 'List product families, paged', + }, + + 'saleInvoices.create': { + riskLevel: 'write', + description: + 'Create a draft sale invoice. Lines use taxExcludedPrice, never unitPrice - unitPrice is silently ignored and prices the line at zero.', + }, + 'saleInvoices.get': { + riskLevel: 'read', + description: 'Get a sale invoice by id', + }, + 'saleInvoices.find': { + riskLevel: 'read', + description: 'Find sale invoices by internalId - returns an array', + }, + 'saleInvoices.list': { + riskLevel: 'read', + description: + 'List sale invoices, paged, filterable by date range, customer and status', + }, + 'saleInvoices.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a draft sale invoice [DESTRUCTIVE]. Drafts only.', + }, + 'saleInvoices.download': { + riskLevel: 'read', + description: + 'Download a sale invoice as PDF. May not be byte-exact - see the core text-decoding note.', + }, + + 'saleCredits.create': { + riskLevel: 'write', + description: 'Create a draft credit note (avoir)', + }, + 'saleCredits.update': { + riskLevel: 'write', + description: + 'Update a draft credit note. Drafts only; lines must be resent in full or the credit is emptied.', + }, + 'saleCredits.get': { + riskLevel: 'read', + description: 'Get a sale credit by id', + }, + 'saleCredits.find': { + riskLevel: 'read', + description: 'Find sale credits by internalId - returns an array', + }, + 'saleCredits.list': { + riskLevel: 'read', + description: + 'List sale credits, paged, filterable by date range and customer', + }, + 'saleCredits.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a draft sale credit [DESTRUCTIVE]. Drafts only.', + }, + 'saleCredits.download': { + riskLevel: 'read', + description: + 'Download a sale credit as PDF. May not be byte-exact - see the core text-decoding note.', + }, + + 'saleQuotes.find': { + riskLevel: 'read', + description: 'Find sale quotes by internalId - returns an array', + }, + 'saleQuotes.list': { + riskLevel: 'read', + description: + 'List sale quotes, paged, filterable by date range and customer. No working status filter - the spec one is a generator artefact.', + }, + 'saleQuotes.delete': { + riskLevel: 'destructive', + irreversible: true, + description: + 'Delete a sale quote [DESTRUCTIVE]. Deleting a quote that does not exist also returns 200.', + }, + + 'receipts.create': { + riskLevel: 'write', + description: + 'Create a receipt. links to a draft document are refused - the document must be finalized first, which is outside this plugin.', + }, + 'receipts.update': { + riskLevel: 'write', + description: + 'Update a receipt. Read-modify-write internally; a customer reference is required even on update.', + }, + 'receipts.get': { riskLevel: 'read', description: 'Get a receipt by id' }, + 'receipts.find': { + riskLevel: 'read', + description: + "Find receipts by the receipt's own internalId - returns an array", + }, + 'receipts.list': { riskLevel: 'read', description: 'List receipts, paged' }, + 'receipts.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a receipt [DESTRUCTIVE]', + }, + + 'purchaseInvoices.upload': { + riskLevel: 'write', + description: + 'Upload a purchase invoice file (PDF or image). There is no delete for this anywhere in the API - only the Altoviz UI can remove it.', + }, + 'purchaseInvoices.download': { + riskLevel: 'read', + description: 'Download a purchase invoice as PDF', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const altovizAuthConfig = { + api_key: {}, +} as const satisfies PluginAuthConfig; + +export type BaseAltovizPlugin = CorsairPlugin< + 'altoviz', + typeof AltovizSchema, + typeof altovizEndpointsNested, + {}, + T, + typeof defaultAuthType +>; + +export type InternalAltovizPlugin = BaseAltovizPlugin; + +export type ExternalAltovizPlugin = + BaseAltovizPlugin; + +/** + * Builds the Altoviz plugin. + * + * A single API key in the `X-API-KEY` header is the entire auth surface - no + * OAuth, no tenant subdomain, no second credential - so `api_key: {}` and + * `ctx.keys.get_api_key()` are all this plugin needs. A missing key raises + * `AuthMissingError` rather than sending an empty header, which the provider + * would answer with a 401 carrying a completely empty body - unhelpful for + * diagnosing a configuration gap. + */ +export function altoviz( + incomingOptions: AltovizPluginOptions & T = {} as AltovizPluginOptions & T, +): ExternalAltovizPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'altoviz', + schema: AltovizSchema, + options, + authConfig: altovizAuthConfig, + hooks: options.hooks, + endpoints: altovizEndpointsNested, + webhooks: {}, + endpointMeta: altovizEndpointMeta, + endpointSchemas: altovizEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AltovizKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + console.error( + '[ALTOVIZ] API key missing - connect Altoviz or pass key in plugin options.', + ); + throw new AuthMissingError('altoviz', 'api_key'); + } + return res; + } + + console.error( + '[ALTOVIZ] Authentication required for Altoviz API requests.', + ); + throw new AuthMissingError('altoviz', 'api_key'); + }, + } satisfies InternalAltovizPlugin; +} + +export type { + AltovizEndpointInputs, + AltovizEndpointOutputs, +} from './endpoints/types'; +export { altovizEndpointsNested }; diff --git a/packages/altoviz/integration.test.ts b/packages/altoviz/integration.test.ts new file mode 100644 index 000000000..0941f7c56 --- /dev/null +++ b/packages/altoviz/integration.test.ts @@ -0,0 +1,219 @@ +/** + * Live Altoviz API checks. + * + * Skipped unless ALTOVIZ_API_KEY is set. Loads gitignored `.env.local` from + * the repo root when present. + * + * Reads reference tables and catalog records, validates them against the + * official-key persist schemas, then creates a disposable customer (with an + * explicit number so numbering-sequence init is not required) and deletes it. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { AltovizAPIError, makeAltovizRequest } from './client'; +import { AltovizEndpointOutputSchemas } from './endpoints/types'; +import { + AltovizClassificationEntity, + AltovizCustomerEntity, + AltovizUnitEntity, + AltovizVatEntity, +} from './schema/database'; + +function loadEnvLocal() { + try { + const text = readFileSync(resolve(__dirname, '../../.env.local'), 'utf8'); + for (const line of text.split('\n')) { + const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.replace(/\r$/, '')); + if (!match) continue; + const key = match[1]; + const value = match[2]; + if (key && value !== undefined && !process.env[key]) { + process.env[key] = value; + } + } + } catch { + // no local env file + } +} + +loadEnvLocal(); + +const apiKey = process.env.ALTOVIZ_API_KEY; +const describeLive = apiKey ? describe : describe.skip; + +function issues(error: { + issues: readonly { path?: PropertyKey[]; code?: string; message?: string }[]; +}): string[] { + return error.issues.map((issue) => { + const where = (issue.path ?? []).join('.') || '(root)'; + return `${where}: ${issue.code ?? 'invalid'} - ${issue.message ?? ''}`; + }); +} + +describeLive('Altoviz live', () => { + const key = apiKey as string; + let customerId: number | undefined; + const probeNumber = `CRSR-${Date.now().toString(36).toUpperCase()}`; + + afterAll(async () => { + if (!customerId) return; + try { + await makeAltovizRequest('v1/customers/{id}', key, { + method: 'DELETE', + path: { id: customerId }, + }); + } catch (error) { + if (!(error instanceof AltovizAPIError && error.status === 404)) { + throw error; + } + } + }); + + test('TEST_API_KEY: GET /hello returns account identity', async () => { + const raw = await makeAltovizRequest('hello', key); + const parsed = + AltovizEndpointOutputSchemas.accountTestApiKey.safeParse(raw); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + expect(parsed.data?.serverTimestamp).toBeTruthy(); + }); + + test('GET_UNITS: official Unit keys persist', async () => { + const raw = await makeAltovizRequest('v1/units', key); + const parsed = AltovizEndpointOutputSchemas.accountGetUnits.safeParse(raw); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + expect(Array.isArray(parsed.data) && parsed.data.length).toBeGreaterThan(0); + for (const unit of parsed.data ?? []) { + const row = AltovizUnitEntity.safeParse(unit); + if (!row.success) console.error(issues(row.error)); + expect(row.success).toBe(true); + } + }); + + test('GET_VATS: official Vat keys persist', async () => { + const raw = await makeAltovizRequest('v1/vats', key); + const parsed = AltovizEndpointOutputSchemas.accountGetVats.safeParse(raw); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + expect(Array.isArray(parsed.data) && parsed.data.length).toBeGreaterThan(0); + for (const vat of parsed.data ?? []) { + const row = AltovizVatEntity.safeParse(vat); + if (!row.success) console.error(issues(row.error)); + expect(row.success).toBe(true); + } + }); + + test('GET_CLASSIFICATIONS: official Classification keys persist', async () => { + const raw = await makeAltovizRequest('v1/classifications', key); + const parsed = + AltovizEndpointOutputSchemas.accountGetClassifications.safeParse(raw); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + for (const classification of parsed.data ?? []) { + const row = AltovizClassificationEntity.safeParse(classification); + if (!row.success) console.error(issues(row.error)); + expect(row.success).toBe(true); + } + }); + + test('LIST_CUSTOMERS: PageIndex is 1-based and rows match Customer', async () => { + const raw = await makeAltovizRequest('v1/customers', key, { + query: { PageIndex: 1, PageSize: 10 }, + }); + const parsed = AltovizEndpointOutputSchemas.customersList.safeParse(raw); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + for (const customer of parsed.data ?? []) { + const row = AltovizCustomerEntity.safeParse(customer); + if (!row.success) console.error(issues(row.error)); + expect(row.success).toBe(true); + } + }); + + test.each([ + ['v1/users/me', 'accountGetCurrentUser'], + ['v1/settings', 'accountGetSettings'], + ['v1/productfamilies', 'productFamiliesList'], + ['v1/customerfamilies', 'customerFamiliesList'], + ['v1/contacts', 'contactsList'], + ['v1/suppliers', 'suppliersList'], + ['v1/colleagues', 'colleaguesList'], + ['v1/saleinvoices', 'saleInvoicesList'], + ['v1/salecredits', 'saleCreditsList'], + ['v1/salequotes', 'saleQuotesList'], + ['v1/receipts', 'receiptsList'], + ['v1/webhooks', 'webhookSubscriptionsList'], + ] as const)('GET %s matches the output schema', async (url, schemaKey) => { + const raw = await makeAltovizRequest(url, key, { + query: + url === 'v1/users/me' || url === 'v1/settings' || url === 'v1/webhooks' + ? undefined + : { PageIndex: 1, PageSize: 5 }, + }); + const parsed = AltovizEndpointOutputSchemas[schemaKey].safeParse(raw); + if (!parsed.success) console.error(url, issues(parsed.error)); + expect(parsed.success).toBe(true); + }); + + test('CREATE_CUSTOMER / GET_CUSTOMER / DELETE_CUSTOMER', async () => { + const created = await makeAltovizRequest<{ id: number }>( + 'v1/customers', + key, + { + method: 'POST', + body: { + type: 'Business', + companyName: 'Corsair probe', + email: `corsair-probe-${probeNumber.toLowerCase()}@example.com`, + number: probeNumber, + }, + }, + ); + expect(created.id).toEqual(expect.any(Number)); + customerId = created.id; + + const got = await makeAltovizRequest('v1/customers/{id}', key, { + path: { id: created.id }, + }); + const parsed = AltovizCustomerEntity.safeParse(got); + if (!parsed.success) console.error(issues(parsed.error)); + expect(parsed.success).toBe(true); + expect(parsed.data?.id).toBe(created.id); + expect(parsed.data?.type).toBe('Business'); + + const updated = await makeAltovizRequest( + 'v1/customers/{id}', + key, + { + method: 'PUT', + path: { id: created.id }, + body: { + id: created.id, + type: 'Business', + companyName: 'Corsair probe 2', + email: parsed.data?.email, + number: probeNumber, + }, + }, + ); + expect(updated.companyName).toBe('Corsair probe 2'); + expect(updated.number).toBe(probeNumber); + + const contacts = await makeAltovizRequest( + 'v1/customers/{id}/contacts', + key, + { path: { id: created.id } }, + ); + expect( + AltovizEndpointOutputSchemas.customersGetContacts.safeParse(contacts) + .success, + ).toBe(true); + + await makeAltovizRequest('v1/customers/{id}', key, { + method: 'DELETE', + path: { id: created.id }, + }); + customerId = undefined; + }); +}); diff --git a/packages/altoviz/jest.config.cjs b/packages/altoviz/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/altoviz/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/altoviz/package.json b/packages/altoviz/package.json new file mode 100644 index 000000000..365b66dc3 --- /dev/null +++ b/packages/altoviz/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/altoviz", + "version": "0.1.0", + "description": "Altoviz plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest --testPathIgnorePatterns=integration.test.ts", + "test:live": "jest --testPathPattern=integration.test.ts" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "altoviz", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/altoviz/routing.test.ts b/packages/altoviz/routing.test.ts new file mode 100644 index 000000000..997875b12 --- /dev/null +++ b/packages/altoviz/routing.test.ts @@ -0,0 +1,725 @@ +/** + * Exercises every one of the 67 operations against a mocked transport: base + * URL, the X-API-KEY header, method, and that the key never appears in the + * query string. A coverage sweep asserts the fixture table below covers + * exactly the registered operation set, so a loop over zero rows cannot pass + * silently and a newly added operation cannot ship untested. + */ + +import { + Account, + Colleagues, + Contacts, + CustomerFamilies, + Customers, + ProductFamilies, + Products, + PurchaseInvoices, + Receipts, + SaleCredits, + SaleInvoices, + SaleQuotes, + Suppliers, + WebhookSubscriptions, +} from './endpoints'; +import { altovizEndpointsNested } from './index'; +import { + installFetchMock, + lastCall, + makeCtx, + makeDb, + queueResponse, + recordedCalls, + requestedHeaders, + resetFetchMock, +} from './test-utils'; + +const BASE = 'https://api.altoviz.com'; + +const unit = { + id: 1, + code: 'H', + name: 'Heures', + type: 'Time', + conversion: 1, + decimals: 0, +}; +const vat = { id: 2, rate: 20, region: 'FR', label: '20% - FR', default: true }; +const customerFamily = { id: 3, label: 'Family', number: 'CRF-001' }; +const productFamily = { id: 4, label: 'Product Family', number: 'PRF-001' }; + +function seededDb() { + const db = makeDb(); + db.units.upsertByEntityId(String(unit.id), unit); + db.vats.upsertByEntityId(String(vat.id), vat); + db.customerFamilies.upsertByEntityId( + String(customerFamily.id), + customerFamily, + ); + db.productFamilies.upsertByEntityId(String(productFamily.id), productFamily); + return db; +} + +type Fixture = { + path: string; + // biome-ignore lint/suspicious/noExplicitAny: a fixture table over 67 differently-typed handlers needs a common call shape + fn: (ctx: any, input: any) => Promise; + input: Record; + method: string; + urlIncludes: string; + response: unknown; +}; + +const line = { + type: 'Service', + productId: 100, + description: 'x', + quantity: 1, + taxExcludedPrice: 10, + unitId: unit.id, + vatId: vat.id, +}; + +const FIXTURES: Fixture[] = [ + { + path: 'customers.create', + fn: Customers.create, + input: { type: 'Business', companyName: 'Acme' }, + method: 'POST', + urlIncludes: 'v1/customers', + response: { id: 1 }, + }, + { + path: 'customers.update', + fn: Customers.update, + input: { customerId: 1, companyName: 'Acme 2' }, + method: 'PUT', + urlIncludes: 'v1/customers/1', + response: { id: 1 }, + }, + { + path: 'customers.delete', + fn: Customers.delete, + input: { customerId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/customers/1', + response: [], + }, + { + path: 'customers.get', + fn: Customers.get, + input: { customerId: 1 }, + method: 'GET', + urlIncludes: 'v1/customers/1', + response: { id: 1 }, + }, + { + path: 'customers.getByInternalId', + fn: Customers.getByInternalId, + input: { internalId: 'ext-1' }, + method: 'GET', + urlIncludes: 'v1/customers/getbyinternalid/ext-1', + response: { id: 1 }, + }, + { + path: 'customers.find', + fn: Customers.find, + input: { email: 'a@example.com' }, + method: 'GET', + urlIncludes: 'v1/customers/find', + response: [], + }, + { + path: 'customers.list', + fn: Customers.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/customers', + response: [], + }, + { + path: 'customers.getContacts', + fn: Customers.getContacts, + input: { customerId: 1 }, + method: 'GET', + urlIncludes: 'v1/customers/1/contacts', + response: [], + }, + + { + path: 'customerFamilies.create', + fn: CustomerFamilies.create, + input: { label: 'F' }, + method: 'POST', + urlIncludes: 'v1/customerfamilies', + response: { id: 3 }, + }, + { + path: 'customerFamilies.get', + fn: CustomerFamilies.get, + input: { familyId: 3 }, + method: 'GET', + urlIncludes: 'v1/customerfamilies/3', + response: { id: 3 }, + }, + { + path: 'customerFamilies.delete', + fn: CustomerFamilies.delete, + input: { familyId: 3 }, + method: 'DELETE', + urlIncludes: 'v1/customerfamilies/3', + response: {}, + }, + { + path: 'customerFamilies.list', + fn: CustomerFamilies.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/customerfamilies', + response: [], + }, + + { + path: 'suppliers.get', + fn: Suppliers.get, + input: { supplierId: 1 }, + method: 'GET', + urlIncludes: 'v1/suppliers/1', + response: { id: 1 }, + }, + { + path: 'suppliers.list', + fn: Suppliers.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/suppliers', + response: [], + }, + { + path: 'suppliers.update', + fn: Suppliers.update, + input: { supplierId: 1, name: 'S' }, + method: 'PUT', + urlIncludes: 'v1/suppliers/1', + response: { id: 1 }, + }, + { + path: 'suppliers.delete', + fn: Suppliers.delete, + input: { supplierId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/suppliers/1', + response: [], + }, + { + path: 'suppliers.getContacts', + fn: Suppliers.getContacts, + input: { supplierId: 1 }, + method: 'GET', + urlIncludes: 'v1/suppliers/1/contacts', + response: [], + }, + + { + path: 'contacts.create', + fn: Contacts.create, + input: { firstName: 'A', lastName: 'B' }, + method: 'POST', + urlIncludes: 'v1/contacts', + response: { id: 1 }, + }, + { + path: 'contacts.get', + fn: Contacts.get, + input: { contactId: 1 }, + method: 'GET', + urlIncludes: 'v1/contacts/1', + response: { id: 1 }, + }, + { + path: 'contacts.find', + fn: Contacts.find, + input: { email: 'a@example.com' }, + method: 'GET', + urlIncludes: 'v1/contacts/find', + response: [], + }, + { + path: 'contacts.list', + fn: Contacts.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/contacts', + response: [], + }, + + { + path: 'colleagues.get', + fn: Colleagues.get, + input: { colleagueId: 1 }, + method: 'GET', + urlIncludes: 'v1/colleagues/1', + response: { id: 1 }, + }, + { + path: 'colleagues.list', + fn: Colleagues.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/colleagues', + response: [], + }, + { + path: 'colleagues.update', + fn: Colleagues.update, + input: { colleagueId: 1, firstName: 'A' }, + method: 'PUT', + urlIncludes: 'v1/colleagues/1', + response: { id: 1 }, + }, + { + path: 'colleagues.delete', + fn: Colleagues.delete, + input: { colleagueId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/colleagues/1', + response: {}, + }, + + { + path: 'account.getCurrentUser', + fn: Account.getCurrentUser, + input: {}, + method: 'GET', + urlIncludes: 'v1/users/me', + response: { userId: 'u1' }, + }, + { + path: 'account.testApiKey', + fn: Account.testApiKey, + input: {}, + method: 'GET', + urlIncludes: 'hello', + response: { apiKeyName: 'k' }, + }, + { + path: 'account.getSettings', + fn: Account.getSettings, + input: {}, + method: 'GET', + urlIncludes: 'v1/settings', + response: {}, + }, + { + path: 'account.getUnits', + fn: Account.getUnits, + input: {}, + method: 'GET', + urlIncludes: 'v1/units', + response: [unit], + }, + { + path: 'account.getVats', + fn: Account.getVats, + input: {}, + method: 'GET', + urlIncludes: 'v1/vats', + response: [vat], + }, + { + path: 'account.getClassifications', + fn: Account.getClassifications, + input: {}, + method: 'GET', + urlIncludes: 'v1/classifications', + response: [], + }, + + { + path: 'webhookSubscriptions.list', + fn: WebhookSubscriptions.list, + input: {}, + method: 'GET', + urlIncludes: 'v1/webhooks', + response: [], + }, + { + path: 'webhookSubscriptions.register', + fn: WebhookSubscriptions.register, + input: { + name: 'W', + url: 'https://example.com/wh', + types: ['CustomerCreated'], + }, + method: 'POST', + urlIncludes: 'v1/webhooks', + response: { id: 0 }, + }, + { + path: 'webhookSubscriptions.unregister', + fn: WebhookSubscriptions.unregister, + input: { webhookId: 9 }, + method: 'DELETE', + urlIncludes: 'v1/webhooks', + response: {}, + }, + + { + path: 'products.create', + fn: Products.create, + input: { + name: 'P', + type: 'Service', + unitId: unit.id, + vatId: vat.id, + familyId: productFamily.id, + }, + method: 'POST', + urlIncludes: 'v1/products', + response: { id: 1 }, + }, + { + path: 'products.delete', + fn: Products.delete, + input: { productId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/products/1', + response: {}, + }, + { + path: 'products.get', + fn: Products.get, + input: { productId: 1 }, + method: 'GET', + urlIncludes: 'v1/products/1', + response: { id: 1 }, + }, + { + path: 'products.find', + fn: Products.find, + input: { number: 'SKU-1' }, + method: 'GET', + urlIncludes: 'v1/products/find', + response: [], + }, + { + path: 'products.findByNumberOrId', + fn: Products.findByNumberOrId, + input: { number: 'SKU-1' }, + method: 'GET', + urlIncludes: 'v1/products/find', + response: [], + }, + + { + path: 'productFamilies.create', + fn: ProductFamilies.create, + input: { label: 'F' }, + method: 'POST', + urlIncludes: 'v1/productfamilies', + response: { id: 4 }, + }, + { + path: 'productFamilies.get', + fn: ProductFamilies.get, + input: { familyId: 4 }, + method: 'GET', + urlIncludes: 'v1/productfamilies/4', + response: { id: 4 }, + }, + { + path: 'productFamilies.delete', + fn: ProductFamilies.delete, + input: { familyId: 4 }, + method: 'DELETE', + urlIncludes: 'v1/productfamilies/4', + response: {}, + }, + { + path: 'productFamilies.list', + fn: ProductFamilies.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/productfamilies', + response: [], + }, + + { + path: 'saleInvoices.create', + fn: SaleInvoices.create, + input: { customerId: 1, date: '2026-01-01', lines: [line] }, + method: 'POST', + urlIncludes: 'v1/saleinvoices', + response: { id: 1 }, + }, + { + path: 'saleInvoices.get', + fn: SaleInvoices.get, + input: { invoiceId: 1 }, + method: 'GET', + urlIncludes: 'v1/saleinvoices/1', + response: { id: 1 }, + }, + { + path: 'saleInvoices.find', + fn: SaleInvoices.find, + input: { internalId: 'x' }, + method: 'GET', + urlIncludes: 'v1/saleinvoices/find', + response: [], + }, + { + path: 'saleInvoices.list', + fn: SaleInvoices.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/saleinvoices', + response: [], + }, + { + path: 'saleInvoices.delete', + fn: SaleInvoices.delete, + input: { invoiceId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/saleinvoices/1', + response: {}, + }, + { + path: 'saleInvoices.download', + fn: SaleInvoices.download, + input: { invoiceId: 1 }, + method: 'GET', + urlIncludes: 'v1/saleinvoices/download/1', + response: '%PDF-1.4', + }, + + { + path: 'saleCredits.create', + fn: SaleCredits.create, + input: { customerId: 1, date: '2026-01-01', lines: [line] }, + method: 'POST', + urlIncludes: 'v1/salecredits', + response: { id: 1 }, + }, + { + path: 'saleCredits.update', + fn: SaleCredits.update, + input: { creditId: 1, lines: [line] }, + method: 'PUT', + urlIncludes: 'v1/salecredits/1', + response: { id: 1 }, + }, + { + path: 'saleCredits.get', + fn: SaleCredits.get, + input: { creditId: 1 }, + method: 'GET', + urlIncludes: 'v1/salecredits/1', + response: { id: 1 }, + }, + { + path: 'saleCredits.find', + fn: SaleCredits.find, + input: { internalId: 'x' }, + method: 'GET', + urlIncludes: 'v1/salecredits/find', + response: [], + }, + { + path: 'saleCredits.list', + fn: SaleCredits.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/salecredits', + response: [], + }, + { + path: 'saleCredits.delete', + fn: SaleCredits.delete, + input: { creditId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/salecredits/1', + response: {}, + }, + { + path: 'saleCredits.download', + fn: SaleCredits.download, + input: { creditId: 1 }, + method: 'GET', + urlIncludes: 'v1/salecredits/download/1', + response: '%PDF-1.4', + }, + + { + path: 'saleQuotes.find', + fn: SaleQuotes.find, + input: { internalId: 'x' }, + method: 'GET', + urlIncludes: 'v1/salequotes/find', + response: [], + }, + { + path: 'saleQuotes.list', + fn: SaleQuotes.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/salequotes', + response: [], + }, + { + path: 'saleQuotes.delete', + fn: SaleQuotes.delete, + input: { quoteId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/salequotes/1', + response: {}, + }, + + { + path: 'receipts.create', + fn: Receipts.create, + input: { amount: 10, date: '2026-01-01', paymentMethod: 'Transfer' }, + method: 'POST', + urlIncludes: 'v1/receipts', + response: { id: 1 }, + }, + { + path: 'receipts.update', + fn: Receipts.update, + input: { receiptId: 1, amount: 20 }, + method: 'PUT', + urlIncludes: 'v1/receipts/1', + response: { id: 1 }, + }, + { + path: 'receipts.get', + fn: Receipts.get, + input: { receiptId: 1 }, + method: 'GET', + urlIncludes: 'v1/receipts/1', + response: { id: 1 }, + }, + { + path: 'receipts.find', + fn: Receipts.find, + input: { internalId: 'x' }, + method: 'GET', + urlIncludes: 'v1/receipts/find', + response: [], + }, + { + path: 'receipts.list', + fn: Receipts.list, + input: { pageIndex: 1 }, + method: 'GET', + urlIncludes: 'v1/receipts', + response: [], + }, + { + path: 'receipts.delete', + fn: Receipts.delete, + input: { receiptId: 1 }, + method: 'DELETE', + urlIncludes: 'v1/receipts/1', + response: {}, + }, + + { + path: 'purchaseInvoices.upload', + fn: PurchaseInvoices.upload, + input: { + fileBase64: Buffer.from('hi').toString('base64'), + fileName: 'a.pdf', + mimeType: 'application/pdf', + }, + method: 'POST', + urlIncludes: 'v1/purchaseinvoices/file', + response: { id: 1 }, + }, + { + path: 'purchaseInvoices.download', + fn: PurchaseInvoices.download, + input: { purchaseInvoiceId: 1 }, + method: 'GET', + urlIncludes: 'v1/purchaseinvoices/download/1', + response: '%PDF-1.4', + }, +]; + +describe('routing', () => { + beforeEach(() => { + resetFetchMock(); + installFetchMock(); + }); + + test('coverage sweep: the fixture table covers exactly the registered operation set', () => { + const registered = new Set(); + for (const [group, ops] of Object.entries(altovizEndpointsNested)) { + for (const op of Object.keys(ops as Record)) { + registered.add(`${group}.${op}`); + } + } + expect(registered.size).toBe(67); + + const fixtured = new Set(FIXTURES.map((f) => f.path)); + expect(fixtured.size).toBe(FIXTURES.length); + expect([...fixtured].sort()).toEqual([...registered].sort()); + }); + + test.each(FIXTURES)( + '$path: correct URL, method, auth header', + async (fixture) => { + const { ctx } = makeCtx(seededDb()); + queueResponse(fixture.response, { + status: + fixture.method === 'POST' && fixture.urlIncludes === 'v1/webhooks' + ? 201 + : 200, + contentType: + typeof fixture.response === 'string' + ? 'application/pdf' + : 'application/json; charset=utf-8', + repeat: true, + }); + + await fixture.fn(ctx, fixture.input); + + const call = lastCall(); + expect(new URL(call.url).origin).toBe(new URL(BASE).origin); + expect(call.url).toContain(fixture.urlIncludes); + expect(call.init.method).toBe(fixture.method); + + const headers = requestedHeaders(call); + expect(headers['x-api-key'] ?? headers['X-API-KEY']).toBe( + 'fake-altoviz-key-for-tests-only', + ); + + // the key must never appear in the query string + expect(call.url).not.toContain('fake-altoviz-key-for-tests-only'); + for (const recorded of recordedCalls()) { + expect(recorded.url).not.toContain('/undefined'); + expect(recorded.url).not.toContain('undefined/'); + } + }, + ); + + test('customers.getByInternalId URL-encodes the caller-supplied internalId', async () => { + const { ctx } = makeCtx(); + queueResponse({ id: 1 }); + await Customers.getByInternalId(ctx, { internalId: 'ext id/with space' }); + expect(lastCall().url).toContain(encodeURIComponent('ext id/with space')); + }); + + test('unregister by url echoes the url, not a fabricated id 0', async () => { + const { ctx } = makeCtx(); + queueResponse({}); + const byUrl = await WebhookSubscriptions.unregister(ctx, { + url: 'https://example.com/wh', + }); + expect(byUrl).toEqual({ deleted: true, url: 'https://example.com/wh' }); + + queueResponse({}); + const byId = await WebhookSubscriptions.unregister(ctx, { webhookId: 9 }); + expect(byId).toEqual({ deleted: true, id: 9 }); + }); +}); diff --git a/packages/altoviz/schema.test.ts b/packages/altoviz/schema.test.ts new file mode 100644 index 000000000..e0b8cecfa --- /dev/null +++ b/packages/altoviz/schema.test.ts @@ -0,0 +1,568 @@ +import { z } from 'zod'; +import { + AltovizEndpointInputSchemas, + AltovizEndpointOutputSchemas, +} from './endpoints/types'; +import { altovizEndpointSchemas } from './index'; +import { AltovizSchema } from './schema'; +import { + AltovizClassificationEntity, + AltovizContactEntity, + AltovizCustomerEntity, + AltovizCustomerFamilyEntity, + AltovizProductEntity, + AltovizProductFamilyEntity, + AltovizUnitEntity, + AltovizVatEntity, +} from './schema/database'; + +/** + * Official schema property names, id-first then OpenAPI order. + * https://developer.altoviz.com/openapi.json + */ +const UNIT_KEYS = [ + 'id', + 'code', + 'conversion', + 'decimals', + 'name', + 'type', +] as const; +const VAT_KEYS = ['id', 'default', 'label', 'rate', 'region'] as const; +const CLASSIFICATION_KEYS = [ + 'id', + 'accountNumber', + 'defaultVat', + 'description', + 'isProduct', + 'isService', + 'label', + 'microBusinessDeclarationType', + 'type', +] as const; +const CUSTOMER_FAMILY_KEYS = ['id', 'internalId', 'label', 'number'] as const; +const PRODUCT_FAMILY_KEYS = ['id', 'label', 'number'] as const; +const PRODUCT_KEYS = [ + 'id', + 'active', + 'defaultQuantity', + 'description', + 'family', + 'imageUrl', + 'internalId', + 'internalNotes', + 'isUnitPriceTaxIncluded', + 'name', + 'number', + 'purchasePrice', + 'type', + 'unit', + 'unitPrice', + 'vat', +] as const; +const CUSTOMER_KEYS = [ + 'id', + 'active', + 'billingAddress', + 'billingOptions', + 'cellPhone', + 'companyInformations', + 'companyName', + 'email', + 'family', + 'firstName', + 'internalId', + 'internalNotes', + 'lastName', + 'name', + 'number', + 'phone', + 'shippingAddress', + 'title', + 'type', +] as const; +const CONTACT_KEYS = [ + 'id', + 'cellPhone', + 'companyName', + 'displayName', + 'email', + 'firstName', + 'function', + 'internalId', + 'invertedDisplayName', + 'lastName', + 'phone', + 'service', + 'title', +] as const; + +function shapeKeys(schema: { shape: object }): string[] { + return Object.keys(schema.shape); +} + +const REFERENCE_ENTITIES = [ + 'units', + 'vats', + 'classifications', + 'customerFamilies', + 'productFamilies', + 'products', + 'customers', + 'contacts', +] as const; + +const NON_MIRRORED_ENTITIES = [ + 'suppliers', + 'colleagues', + 'webhooks', + 'saleInvoices', + 'saleCredits', + 'saleQuotes', + 'receipts', + 'purchaseInvoices', +] as const; + +const SALE_DOCUMENT_FIELDS = [ + 'id', + 'number', + 'internalId', + 'date', + 'subject', + 'customerId', + 'customerName', + 'customerNumber', + 'customerType', + 'customerElectronicAddress', + 'customerOrderReference', + 'customerSiret', + 'customerVatNumber', + 'billingAddress', + 'shippingAddress', + 'billingContact', + 'shippingContact', + 'headerNotes', + 'footerNotes', + 'internalNotes', + 'lines', + 'globalDiscount', + 'shippingAmount', + 'shippingVat', + 'balance', + 'grossTaxExcludedAmount', + 'taxAmount', + 'taxExcludedAmount', + 'taxIncludedAmount', + 'useTaxIncludedPrices', + 'liableToVat', + 'vatMode', + 'vatNote', + 'vatReverseCharge', + 'region', + 'vats', + 'metadata', + 'pdfUrl', + 'publicLink', + 'sentAt', + 'commitments', + 'overdue', + 'vendorReference', +] as const; + +const CAPTURED_TOP_LEVEL_FIELDS = [ + { + name: 'customer', + schema: AltovizEndpointOutputSchemas.customersGet, + fields: [ + 'id', + 'type', + 'companyName', + 'firstName', + 'lastName', + 'name', + 'email', + 'phone', + 'cellPhone', + 'title', + 'number', + 'internalId', + 'internalNotes', + 'active', + 'billingAddress', + 'shippingAddress', + 'billingOptions', + 'companyInformations', + 'family', + ], + }, + { + name: 'customer family', + schema: AltovizEndpointOutputSchemas.customerFamiliesGet, + fields: ['id', 'label', 'number', 'internalId'], + }, + { + name: 'contact', + schema: AltovizEndpointOutputSchemas.contactsGet, + fields: [ + 'id', + 'displayName', + 'invertedDisplayName', + 'firstName', + 'lastName', + 'companyName', + 'email', + 'phone', + 'cellPhone', + 'function', + 'service', + 'title', + 'internalId', + ], + }, + { + name: 'supplier', + schema: AltovizEndpointOutputSchemas.suppliersGet, + fields: [ + 'id', + 'name', + 'firstName', + 'lastName', + 'email', + 'phone', + 'cellPhone', + 'title', + 'number', + 'internalId', + 'internalNotes', + 'active', + 'address', + 'defaultPaymentMethod', + 'companyInformations', + 'createdAt', + 'createdById', + 'updatedAt', + 'updatedById', + ], + }, + { + name: 'colleague', + schema: AltovizEndpointOutputSchemas.colleaguesGet, + fields: [ + 'id', + 'firstName', + 'lastName', + 'name', + 'email', + 'phone', + 'cellPhone', + 'title', + 'number', + 'internalId', + 'isPartner', + 'initialPartnerBalance', + 'homecareServiceNumber', + 'userId', + 'metadatas', + ], + }, + { + name: 'product', + schema: AltovizEndpointOutputSchemas.productsGet, + fields: [ + 'id', + 'name', + 'number', + 'description', + 'type', + 'unitPrice', + 'purchasePrice', + 'isUnitPriceTaxIncluded', + 'defaultQuantity', + 'unit', + 'vat', + 'family', + 'imageUrl', + 'internalId', + 'internalNotes', + 'active', + ], + }, + { + name: 'product family', + schema: AltovizEndpointOutputSchemas.productFamiliesGet, + fields: ['id', 'label', 'number'], + }, + { + name: 'sale invoice', + schema: AltovizEndpointOutputSchemas.saleInvoicesGet, + fields: [ + ...SALE_DOCUMENT_FIELDS, + 'isDraft', + 'isPaid', + 'isCancelled', + 'isProforma', + 'cancellationCreditId', + 'cancellationCreditNumber', + 'cancelledCreditId', + 'cancelledCreditNumber', + 'eInvoicingInvoiceId', + 'eInvoicingProviderId', + 'eInvoicingStatus', + 'replacedBy', + ], + }, + { + name: 'sale credit', + schema: AltovizEndpointOutputSchemas.saleCreditsGet, + fields: [ + ...SALE_DOCUMENT_FIELDS, + 'isDraft', + 'isPaid', + 'isCancelled', + 'cancelledInvoicetId', + 'cancelledInvoicetNumber', + 'cancellationInvoiceId', + 'cancellationInvoiceNumber', + 'replacedBy', + ], + }, + { + name: 'sale quote', + schema: AltovizEndpointOutputSchemas.saleQuotesFind, + fields: [ + ...SALE_DOCUMENT_FIELDS, + 'status', + 'validityDate', + 'deposit', + 'acceptedAt', + 'refusedAt', + ], + }, + { + name: 'receipt', + schema: AltovizEndpointOutputSchemas.receiptsGet, + fields: [ + 'id', + 'amount', + 'date', + 'paymentMethod', + 'status', + 'reference', + 'notes', + 'customerId', + 'customerName', + 'customerNumber', + 'customerInternalId', + 'internalId', + 'links', + 'metadata', + ], + }, + { + name: 'unit', + schema: AltovizEndpointOutputSchemas.accountGetUnits, + fields: ['id', 'code', 'name', 'type', 'conversion', 'decimals'], + }, + { + name: 'vat', + schema: AltovizEndpointOutputSchemas.accountGetVats, + fields: ['id', 'rate', 'region', 'label', 'default'], + }, + { + name: 'classification', + schema: AltovizEndpointOutputSchemas.accountGetClassifications, + fields: [ + 'id', + 'label', + 'description', + 'type', + 'accountNumber', + 'isProduct', + 'isService', + 'defaultVat', + 'microBusinessDeclarationType', + ], + }, + { + name: 'current user', + schema: AltovizEndpointOutputSchemas.accountGetCurrentUser, + fields: [ + 'userId', + 'displayName', + 'firstName', + 'lastName', + 'email', + 'phone', + 'profile', + 'status', + ], + }, + { + name: 'hello', + schema: AltovizEndpointOutputSchemas.accountTestApiKey, + fields: ['apiKeyName', 'companyName', 'message', 'serverTimestamp', 'url'], + }, + { + name: 'webhook', + schema: AltovizEndpointOutputSchemas.webhookSubscriptionsList, + fields: ['id', 'name', 'url', 'types', 'secretKey'], + }, + { + name: 'purchase invoice', + schema: AltovizEndpointOutputSchemas.purchaseInvoicesUpload, + fields: [ + 'id', + 'date', + 'reference', + 'subject', + 'notes', + 'region', + 'status', + 'supplier', + 'taxIncludedAmount', + 'vatReverseCharge', + 'pdfUrl', + ], + }, +] as const; + +function objectShape(schema: z.ZodType): Record { + const unwrapped = schema instanceof z.ZodArray ? schema.element : schema; + expect(unwrapped).toBeInstanceOf(z.ZodObject); + return (unwrapped as z.ZodObject).shape as Record; +} + +describe('Altoviz persisted schema', () => { + test('declares a semver version and exactly the eight reference stores', () => { + expect(AltovizSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(Object.keys(AltovizSchema.entities)).toEqual(REFERENCE_ENTITIES); + }); + + test.each(REFERENCE_ENTITIES)( + '%s accepts key-only, nullable and future-field rows', + (name) => { + const schema = AltovizSchema.entities[name]; + const fields = Object.keys(schema.shape); + expect(fields.length).toBeGreaterThan(1); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ id: 1234567 }).success).toBe(true); + + const nullableRow = Object.fromEntries( + fields.map((field) => [field, field === 'id' ? 1234567 : null]), + ); + expect(schema.safeParse(nullableRow).success).toBe(true); + expect(schema.parse({ id: 1234567, futureField: 'kept' })).toHaveProperty( + 'futureField', + 'kept', + ); + }, + ); + + test('does not expand the mirror beyond the selected reference stores', () => { + for (const name of NON_MIRRORED_ENTITIES) { + expect(AltovizSchema.entities).not.toHaveProperty(name); + } + }); + + test('declares every official Unit field', () => { + expect(shapeKeys(AltovizUnitEntity)).toEqual([...UNIT_KEYS]); + }); + + test('declares every official Vat field', () => { + expect(shapeKeys(AltovizVatEntity)).toEqual([...VAT_KEYS]); + }); + + test('declares every official Classification field', () => { + expect(shapeKeys(AltovizClassificationEntity)).toEqual([ + ...CLASSIFICATION_KEYS, + ]); + }); + + test('declares every official CustomerFamily field', () => { + expect(shapeKeys(AltovizCustomerFamilyEntity)).toEqual([ + ...CUSTOMER_FAMILY_KEYS, + ]); + }); + + test('declares every official ProductFamily field', () => { + expect(shapeKeys(AltovizProductFamilyEntity)).toEqual([ + ...PRODUCT_FAMILY_KEYS, + ]); + }); + + test('declares every official Product field', () => { + expect(shapeKeys(AltovizProductEntity)).toEqual([...PRODUCT_KEYS]); + expect(shapeKeys(AltovizProductEntity)).not.toContain('unit_code'); + expect(shapeKeys(AltovizProductEntity)).not.toContain('vat_rate'); + expect(shapeKeys(AltovizProductEntity)).not.toContain('family_id'); + }); + + test('declares every official Customer field', () => { + expect(shapeKeys(AltovizCustomerEntity)).toEqual([...CUSTOMER_KEYS]); + }); + + test('declares every official Contact field', () => { + expect(shapeKeys(AltovizContactEntity)).toEqual([...CONTACT_KEYS]); + }); + + test('accepts the OpenAPI Product example with nested unit/vat/family', () => { + const parsed = AltovizProductEntity.safeParse({ + id: 42, + name: 'Hour of consulting', + number: 'CONSULT-H', + description: 'Advisory', + type: 'Service', + unitPrice: 120, + purchasePrice: 0, + isUnitPriceTaxIncluded: false, + defaultQuantity: 1, + active: true, + unit: { id: 1, code: 'H', name: 'Hour', type: 'Time' }, + vat: { id: 10, rate: 20, region: 'FR', label: 'TVA 20%', default: true }, + family: { id: 3, label: 'Services', number: 'SRV' }, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.unit?.code).toBe('H'); + expect(parsed.data.vat?.rate).toBe(20); + expect(parsed.data.family?.label).toBe('Services'); + } + }); +}); + +describe('Altoviz endpoint schemas', () => { + test('coverage sweep: all 67 registered operations have input and output schemas', () => { + expect(Object.keys(altovizEndpointSchemas)).toHaveLength(67); + expect(Object.keys(AltovizEndpointInputSchemas)).toHaveLength(67); + expect(Object.keys(AltovizEndpointOutputSchemas)).toHaveLength(67); + for (const schemas of Object.values(altovizEndpointSchemas)) { + expect(typeof schemas.input.safeParse).toBe('function'); + expect(typeof schemas.output.safeParse).toBe('function'); + } + }); + + test.each(CAPTURED_TOP_LEVEL_FIELDS)( + '$name declares every top-level field observed in the live capture', + ({ schema, fields }) => { + const declared = Object.keys(objectShape(schema)); + const missing = fields.filter((field) => !declared.includes(field)); + expect(missing).toEqual([]); + }, + ); + + test('settings remains forward-compatible with its large provider-owned shape', () => { + const capturedShape = { + accounting: { salesJournalCode: 'VE' }, + company: { name: 'Fictional Company' }, + eInvoicing: { profile: 'None' }, + emailing: {}, + general: { timezone: 'Europe/Paris' }, + sales: { useTaxIncluded: false }, + socials: {}, + vat: { liableToVat: true }, + }; + expect( + AltovizEndpointOutputSchemas.accountGetSettings.parse(capturedShape), + ).toEqual(capturedShape); + }); +}); diff --git a/packages/altoviz/schema/database.ts b/packages/altoviz/schema/database.ts new file mode 100644 index 000000000..a896d171e --- /dev/null +++ b/packages/altoviz/schema/database.ts @@ -0,0 +1,283 @@ +import { z } from 'zod'; +import { B, Id, N, S } from './primitives'; + +/** + * Field names match official JSON keys. + * https://developer.altoviz.com/openapi.json + * https://developer.altoviz.com/api + * + * Eight reference stores: units, VAT rates, classifications, the two family + * groupings, products, customers, and contacts. Nested writes resolve + * unit/VAT/family by value (`{code}`, `{rate,region}`, `{label,number}`), + * and the mirror is what turns a caller id into that shape. + * + * Contacts are mirrored because creating a customer, supplier or colleague + * auto-creates one, and deleting the parent leaves that contact behind. + * + * Sale invoices, credits, quotes and receipts are not stored. Their status + * changes server-side without this plugin being told. + */ + +/** https://developer.altoviz.com/openapi.json#/components/schemas/UnitType */ +export const ALTOVIZ_UNIT_TYPE = [ + 'Time', + 'Weight', + 'Area', + 'Volume', + 'Dimension', + 'Other', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/VatRegion */ +export const ALTOVIZ_VAT_REGION = [ + 'FR', + 'EU', + 'IE', + 'DOM', + 'Corse', + 'Monaco', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/ClassificationType */ +export const ALTOVIZ_CLASSIFICATION_TYPE = [ + 'Sale', + 'Expense', + 'Other', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/ProductType */ +export const ALTOVIZ_PRODUCT_TYPE = ['Product', 'Service', 'Text'] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/CustomerType */ +export const ALTOVIZ_CUSTOMER_TYPE = [ + 'Business', + 'Consumer', + 'Government', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/PaymentMethod */ +export const ALTOVIZ_PAYMENT_METHOD = [ + 'Transfer', + 'Order', + 'Check', + 'Cash', + 'Card', + 'Bill', + 'Usec', + 'Other', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/MicroBusinessDeclarationTypes */ +export const ALTOVIZ_MICRO_BUSINESS_DECLARATION_TYPE = [ + 'Products', + 'Services', + 'OtherServices', + 'Renting', + 'Cipav', +] as const; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/AddressFields */ +export const AltovizAddress = z + .object({ + city: S, + countryIso: S, + countryName: S, + formattedAddress: S, + inlineAddress: S, + street: S, + zipcode: S, + }) + .loose(); +export type AltovizAddress = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/CompanyInfo */ +export const AltovizCompanyInfo = z + .object({ + effectiveElectronicAddress: S, + electronicAddress: S, + siret: S, + vatNumber: S, + }) + .loose(); +export type AltovizCompanyInfo = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Discount */ +export const AltovizDiscount = z + .object({ + type: S, + value: N, + }) + .loose(); +export type AltovizDiscount = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/BillingOptions */ +export const AltovizBillingOptions = z + .object({ + allowed: B, + bankAccountId: N, + buyerReference: S, + colleagueId: N, + discount: AltovizDiscount.nullable().optional(), + initialCommitmentAmount: N, + initialCommitmentDate: S, + initialCommitmentStatus: S, + liableToVat: B, + paymentMethod: S, + sendDocumentAsAttachment: B, + settlementTermId: N, + useTaxIncludedPrices: B, + vatReverseCharge: B, + vendorReference: S, + }) + .loose(); +export type AltovizBillingOptions = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Unit */ +export const AltovizUnitEntity = z + .object({ + id: Id, + code: S, + conversion: N, + decimals: N, + name: S, + type: S, + }) + .loose(); +export type AltovizUnitEntity = z.infer; + +/** Embedded Unit on Product; id is nullable in the official schema. */ +export const AltovizUnitRef = AltovizUnitEntity.partial().nullable().optional(); + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Vat */ +export const AltovizVatEntity = z + .object({ + id: Id, + default: B, + label: S, + rate: N, + region: S, + }) + .loose(); +export type AltovizVatEntity = z.infer; + +export const AltovizVatRef = AltovizVatEntity.partial().nullable().optional(); + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Classification */ +export const AltovizClassificationEntity = z + .object({ + id: Id, + accountNumber: S, + defaultVat: AltovizVatRef, + description: S, + isProduct: B, + isService: B, + label: S, + microBusinessDeclarationType: S, + type: S, + }) + .loose(); +export type AltovizClassificationEntity = z.infer< + typeof AltovizClassificationEntity +>; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/CustomerFamily */ +export const AltovizCustomerFamilyEntity = z + .object({ + id: Id, + internalId: S, + label: S, + number: S, + }) + .loose(); +export type AltovizCustomerFamilyEntity = z.infer< + typeof AltovizCustomerFamilyEntity +>; + +export const AltovizCustomerFamilyRef = AltovizCustomerFamilyEntity.partial() + .nullable() + .optional(); + +/** https://developer.altoviz.com/openapi.json#/components/schemas/ProductFamily */ +export const AltovizProductFamilyEntity = z + .object({ + id: Id, + label: S, + number: S, + }) + .loose(); +export type AltovizProductFamilyEntity = z.infer< + typeof AltovizProductFamilyEntity +>; + +export const AltovizProductFamilyRef = AltovizProductFamilyEntity.partial() + .nullable() + .optional(); + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Product */ +export const AltovizProductEntity = z + .object({ + id: Id, + active: B, + defaultQuantity: N, + description: S, + family: AltovizProductFamilyRef, + imageUrl: S, + internalId: S, + internalNotes: S, + isUnitPriceTaxIncluded: B, + name: S, + number: S, + purchasePrice: N, + type: S, + unit: AltovizUnitRef, + unitPrice: N, + vat: AltovizVatRef, + }) + .loose(); +export type AltovizProductEntity = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Customer */ +export const AltovizCustomerEntity = z + .object({ + id: Id, + active: B, + billingAddress: AltovizAddress.nullable().optional(), + billingOptions: AltovizBillingOptions.nullable().optional(), + cellPhone: S, + companyInformations: AltovizCompanyInfo.nullable().optional(), + companyName: S, + email: S, + family: AltovizCustomerFamilyRef, + firstName: S, + internalId: S, + internalNotes: S, + lastName: S, + name: S, + number: S, + phone: S, + shippingAddress: AltovizAddress.nullable().optional(), + title: S, + type: S, + }) + .loose(); +export type AltovizCustomerEntity = z.infer; + +/** https://developer.altoviz.com/openapi.json#/components/schemas/Contact */ +export const AltovizContactEntity = z + .object({ + id: Id, + cellPhone: S, + companyName: S, + displayName: S, + email: S, + firstName: S, + function: S, + internalId: S, + invertedDisplayName: S, + lastName: S, + phone: S, + service: S, + title: S, + }) + .loose(); +export type AltovizContactEntity = z.infer; diff --git a/packages/altoviz/schema/index.ts b/packages/altoviz/schema/index.ts new file mode 100644 index 000000000..b8df3d711 --- /dev/null +++ b/packages/altoviz/schema/index.ts @@ -0,0 +1,27 @@ +import { + AltovizClassificationEntity, + AltovizContactEntity, + AltovizCustomerEntity, + AltovizCustomerFamilyEntity, + AltovizProductEntity, + AltovizProductFamilyEntity, + AltovizUnitEntity, + AltovizVatEntity, +} from './database'; + +export const AltovizSchema = { + version: '1.0.0', + entities: { + units: AltovizUnitEntity, + vats: AltovizVatEntity, + classifications: AltovizClassificationEntity, + customerFamilies: AltovizCustomerFamilyEntity, + productFamilies: AltovizProductFamilyEntity, + products: AltovizProductEntity, + customers: AltovizCustomerEntity, + contacts: AltovizContactEntity, + }, +} as const; + +export * from './database'; +export * from './primitives'; diff --git a/packages/altoviz/schema/primitives.ts b/packages/altoviz/schema/primitives.ts new file mode 100644 index 000000000..6ffca1e07 --- /dev/null +++ b/packages/altoviz/schema/primitives.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +/** + * Shared field builders for persisted Altoviz entities. + * Official JSON omits or nulls optional fields. + * https://developer.altoviz.com/openapi.json + */ +export const S = z.string().nullable().optional(); +export const N = z.number().nullable().optional(); +export const B = z.boolean().nullable().optional(); +export const Id = z.number(); +export const Obj = z.record(z.string(), z.unknown()).nullable().optional(); diff --git a/packages/altoviz/test-utils.ts b/packages/altoviz/test-utils.ts new file mode 100644 index 000000000..9189b1b74 --- /dev/null +++ b/packages/altoviz/test-utils.ts @@ -0,0 +1,133 @@ +/** Shared mock-fetch and context helpers for the Altoviz test suites. */ + +export type Store = { + upsertByEntityId: jest.Mock; + deleteByEntityId: jest.Mock; + findByEntityId: jest.Mock; +}; + +export function makeStore(seed: Record = {}): Store { + const rows = new Map(Object.entries(seed)); + return { + upsertByEntityId: jest.fn(async (id: string, data: unknown) => { + rows.set(id, data); + }), + deleteByEntityId: jest.fn(async (id: string) => { + rows.delete(id); + }), + findByEntityId: jest.fn(async (id: string) => + rows.has(id) ? { data: rows.get(id) } : null, + ), + }; +} + +export function makeDb() { + return { + units: makeStore(), + vats: makeStore(), + classifications: makeStore(), + customerFamilies: makeStore(), + productFamilies: makeStore(), + products: makeStore(), + customers: makeStore(), + contacts: makeStore(), + }; +} + +export function makeCtx(db: ReturnType = makeDb()) { + const ctx = { + key: 'fake-altoviz-key-for-tests-only', + db, + database: undefined, + $getAccountId: async () => 'test-account', + } as never; + return { ctx, db }; +} + +export type RecordedCall = { url: string; init: RequestInit }; + +let calls: RecordedCall[] = []; +let queue: Array<{ + body: unknown; + status?: number; + contentType?: string | null; + headers?: Record; + repeat?: boolean; +}> = []; + +export function resetFetchMock() { + calls = []; + queue = []; +} + +/** Queues one response per successive fetch call, in order. */ +export function queueResponse( + body: unknown, + options: { + status?: number; + contentType?: string | null; + headers?: Record; + repeat?: boolean; + } = {}, +) { + queue.push({ body, ...options }); +} + +/** Consume queued responses in order. After the queue is empty, throw — pass `{ repeat: true }` to reuse the last item. */ +export function installFetchMock() { + global.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const next = queue[0]; + if (!next) + throw new Error('installFetchMock: no response queued for ' + url); + if (!next.repeat) queue.shift(); + const status = next.status ?? 200; + const contentType = + next.contentType === undefined + ? 'application/json; charset=utf-8' + : next.contentType; + const bodyText = + typeof next.body === 'string' ? next.body : JSON.stringify(next.body); + const headers = new Headers(next.headers ?? {}); + if (contentType) headers.set('Content-Type', contentType); + return { + ok: status >= 200 && status < 300, + status, + statusText: status >= 200 && status < 300 ? 'OK' : 'Error', + url, + headers, + json: async () => JSON.parse(bodyText), + text: async () => bodyText, + arrayBuffer: async () => new TextEncoder().encode(bodyText).buffer, + } as unknown as Response; + }) as unknown as typeof global.fetch; +} + +export function recordedCalls(): RecordedCall[] { + return calls; +} + +export function lastCall(): RecordedCall { + if (calls.length === 0) throw new Error('no request was made'); + return calls[calls.length - 1]!; +} + +export function requestedBody(call: RecordedCall = lastCall()): unknown { + if (!call.init.body) return undefined; + return JSON.parse(String(call.init.body)); +} + +export function requestedHeaders( + call: RecordedCall = lastCall(), +): Record { + const h = call.init.headers as Record | Headers | undefined; + if (!h) return {}; + if (h instanceof Headers) { + const out: Record = {}; + h.forEach((v, k) => { + out[k] = v; + }); + return out; + } + return h; +} diff --git a/packages/altoviz/tsconfig.json b/packages/altoviz/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/altoviz/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/altoviz/tsup.config.ts b/packages/altoviz/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/altoviz/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index a047f079f..d25af1afd 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -28,6 +28,7 @@ export const BaseProviders = [ 'alchemy', 'algolia', 'alphavantage', + 'altoviz', 'alttextai', 'amara', 'ambee', @@ -174,6 +175,7 @@ export const ProviderDisplayNames = { alchemy: 'Alchemy', algolia: 'Algolia', alphavantage: 'Alpha Vantage', + altoviz: 'Altoviz', alttextai: 'AltText.ai', amara: 'Amara', ambee: 'Ambee', @@ -327,6 +329,7 @@ export type AllProviders = | 'alchemy' | 'algolia' | 'alphavantage' + | 'altoviz' | 'alttextai' | 'amara' | 'ambee' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e601a51e..0ffb78a30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/altoviz: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/alttextai: devDependencies: '@types/jest':