feat(sync): validate Hiboutik payloads, read French dates and guard deletions

- Hiboutik payloads are zod schemas, validated by HiboutikClient (getOne
  added for single resources: null on 404 or empty answer).
- Hiboutik dates are read as Europe/Paris whatever the server time zone.
- Paginated lists driving deletions are walked twice when they span several
  pages (brands, categories, products); categories are sorted by id.
- A customer missing from the list is deleted only once Hiboutik confirms
  it; completed orders that lost their customer are linked back.
- Each product and its versions are written in one transaction.
- An email removed in Hiboutik is erased in database.
- A 429 is retried with backoff, then stops the synchronization.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-08 12:20:58 +01:00
parent 4b0cda1740
commit 35a7c27cb7
44 changed files with 1065 additions and 467 deletions

View File

@@ -1,7 +1,12 @@
/** Brand as returned by Hiboutik `GET /brands`. */
export interface BrandHiboutikDto {
brand_id: number;
brand_name: string;
import { z } from 'zod';
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
/** Brand as returned by Hiboutik `GET /brands` (only the fields used by the sync). */
export const brandHiboutikSchema = z.object({
brand_id: hiboutikId,
brand_name: hiboutikText,
/** 1 = enabled, 0 = disabled. */
brand_enabled: number;
}
brand_enabled: hiboutikInt,
});
export type BrandHiboutikDto = z.output<typeof brandHiboutikSchema>;

View File

@@ -1,18 +1,18 @@
/** Category as returned by Hiboutik `GET /categories`. */
export interface CategoryHiboutikDto {
category_id: number;
category_name: string;
import { z } from 'zod';
import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */
export const categoryHiboutikSchema = z.object({
category_id: hiboutikId,
category_name: hiboutikText,
/** Id of the parent category, 0 for a root category. */
category_id_parent: number;
category_id_parent: hiboutikRef,
/** Enabled in the POS: 1 = yes, 0 = no. */
category_enabled: number;
category_enabled: hiboutikInt,
/** Displayed on the website: 1 = yes, 0 = no. */
category_enabled_www: number;
category_enabled_www: hiboutikInt,
/** Display order among its siblings. */
category_position: number;
category_bck_color: string;
category_color: string;
category_desc: string;
accounting_account: string;
category_ref_ext: string;
}
category_position: hiboutikInt,
});
export type CategoryHiboutikDto = z.output<typeof categoryHiboutikSchema>;

View File

@@ -1,91 +1,65 @@
/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */
export interface CustomerHiboutikDto {
customers_id: number;
last_name: string;
first_name: string;
company: string;
email: string;
phone: string;
vat: string;
country: string;
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
date_of_birth: string;
customers_code: string;
loyalty_points: number;
intial_loyalty_points: number;
validity: string;
prepaid_purchases: string;
store_credit: string;
turnover: string;
orders: number;
customer_ref_ext: string;
last_order_date: string;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: string;
}
import { z } from 'zod';
import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
export interface FullCustomerHiboutikDto extends CustomerHiboutikDto {
gender: number;
comments: string;
customers_id_referrer: number;
customers_misc: string;
accounting_account: string;
addresses: CustomerAddressHiboutikDto[];
sales: CustomerSaleHiboutikDto[];
tags: unknown[];
customers_referee: unknown[];
}
/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */
export const customerHiboutikSchema = z.object({
customers_id: hiboutikId,
});
/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */
export interface CustomerAddressHiboutikDto {
address_id: number;
gender: string;
first_name: string;
last_name: string;
creation_date: string;
email: string;
phone: string;
company: string;
address: string;
zip_code: string;
city: string;
state: string;
country: string;
customers_id: number;
display: number;
editable: number;
/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */
export const customerAddressHiboutikSchema = z.object({
address_id: hiboutikId,
first_name: hiboutikText,
last_name: hiboutikText,
phone: hiboutikText,
company: hiboutikText,
address: hiboutikText,
zip_code: hiboutikText,
city: hiboutikText,
state: hiboutikText,
country: hiboutikText,
customers_id: hiboutikRef,
/** 1 for the customer's default address. */
default: number;
other: string;
}
default: hiboutikInt,
other: hiboutikText,
});
/**
* Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company
* details and its last update. One API call per address, so only fetched for addresses with a company.
* details. One API call per address, so only fetched for addresses with a company.
*/
export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto {
tax_number: string;
company_number: string;
legal_status: string;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: string;
}
export const customerAddressDetailsHiboutikSchema = customerAddressHiboutikSchema.extend({
tax_number: hiboutikText,
company_number: hiboutikText,
legal_status: hiboutikText,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: hiboutikText,
});
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
export interface CustomerSaleHiboutikDto {
sale_id: number;
created_at: string;
completed_at: string;
date_z: number;
store_id: number;
vendor_id: number;
unique_sale_id: string;
customer_id: number;
currency: string;
/** Decimal as a string. */
total: string;
points: number;
billing_address: number;
shipping_address: number;
}
/** Sale header embedded in the full customer: no sale lines, they come from `GET /sales/:sale_id`. */
export const customerSaleHiboutikSchema = z.object({
sale_id: hiboutikId,
});
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
export const fullCustomerHiboutikSchema = z.object({
customers_id: hiboutikId,
last_name: hiboutikText,
first_name: hiboutikText,
email: hiboutikText,
phone: hiboutikText,
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
date_of_birth: hiboutikText,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: hiboutikText,
/** `undefined` when not an array: the stored addresses are then kept. */
addresses: hiboutikOptionalList(customerAddressHiboutikSchema),
/** `undefined` when not an array: the orders sync is then skipped. */
sales: hiboutikOptionalList(customerSaleHiboutikSchema),
});
export type CustomerHiboutikDto = z.output<typeof customerHiboutikSchema>;
export type CustomerAddressHiboutikDto = z.output<typeof customerAddressHiboutikSchema>;
export type CustomerAddressDetailsHiboutikDto = z.output<typeof customerAddressDetailsHiboutikSchema>;
export type CustomerSaleHiboutikDto = z.output<typeof customerSaleHiboutikSchema>;
export type FullCustomerHiboutikDto = z.output<typeof fullCustomerHiboutikSchema>;

View File

@@ -0,0 +1,87 @@
import { parseHiboutik } from './hiboutik.schema';
import { customerAddressHiboutikSchema, fullCustomerHiboutikSchema } from './customer.hiboutik.dto';
import { productHiboutikSchema } from './product.hiboutik.dto';
import { saleHiboutikSchema } from './sale.hiboutik.dto';
import { stockHiboutikSchema } from './stock.hiboutik.dto';
const parse = <T extends Parameters<typeof parseHiboutik>[0]>(schema: T, data: unknown) => parseHiboutik(schema, data, '/x');
describe('Hiboutik schemas', () => {
const product = {
product_id: 1,
product_model: 'BT21 bottle',
product_barcode: '8435497280048',
product_brand: 1,
product_supplier: 2,
product_price: '13.99',
product_discount_price: '0.00',
product_category: 19,
product_display_www: 1,
product_arch: 0,
products_desc: '',
updated_at: '2026-06-28 17:02:55',
product_specific_rules: [],
};
it('accepts ids, references and flags sent as strings', () => {
const parsed = parse(customerAddressHiboutikSchema, { address_id: ' 8 ', customers_id: '43', default: '0' });
expect(parsed.address_id).toBe(8);
expect(parsed.customers_id).toBe(43);
expect(parsed.default).toBe(0);
});
it('turns empty references into 0 and empty text into ""', () => {
const parsed = parse(productHiboutikSchema, { ...product, product_brand: '', product_category: null, products_desc: null });
expect(parsed.product_brand).toBe(0);
expect(parsed.product_category).toBe(0);
expect(parsed.products_desc).toBe('');
});
it('keeps numeric text (zip code) and prices as strings', () => {
expect(parse(customerAddressHiboutikSchema, { address_id: 1, zip_code: 75002 }).zip_code).toBe('75002');
expect(parse(productHiboutikSchema, { ...product, product_price: 13.5 }).product_price).toBe('13.5');
});
it('defaults missing lists to [] and strips unknown fields', () => {
const parsed = parse(productHiboutikSchema, { ...product, product_specific_rules: '', product_supply_price: '7.49' });
expect(parsed.product_specific_rules).toEqual([]);
expect(parsed.product_size_details).toEqual([]);
expect(parsed).not.toHaveProperty('product_supply_price');
expect(parse(saleHiboutikSchema, { sale_id: 5, total: '0.00' }).line_items).toEqual([]);
});
it('leaves addresses and sales undefined when they are not arrays, so that stored data is kept', () => {
const parsed = parse(fullCustomerHiboutikSchema, { customers_id: 1, addresses: '', sales: { error: 'x' } });
expect(parsed.addresses).toBeUndefined();
expect(parsed.sales).toBeUndefined();
});
it('parses stocks sent as decimal strings', () => {
expect(parse(stockHiboutikSchema, { product_id: '1', product_size: '0', stock_available: '3.000' })).toEqual({
product_id: 1,
product_size: 0,
stock_available: 3,
});
});
it.each([
['a missing id', { ...product, product_id: undefined }],
['a zero id', { ...product, product_id: 0 }],
['a non-numeric id', { ...product, product_id: 'abc' }],
['a negative reference', { ...product, product_brand: -1 }],
['an invalid price', { ...product, product_price: 'free' }],
['a list that is not an array', { ...product, product_size_details: { size_id: 1 } }],
])('rejects %s', (_, data) => {
expect(() => parse(productHiboutikSchema, data)).toThrow('Unexpected answer from Hiboutik /x');
});
it('names the faulty fields, never their values', () => {
expect.assertions(2);
try {
parse(fullCustomerHiboutikSchema, { customers_id: 'jane@example.com', addresses: [{ address_id: 'Jane Doe' }] });
} catch (error) {
expect((error as Error).message).toMatch(/customers_id.*addresses\.0\.address_id/);
expect((error as Error).message).not.toMatch(/jane|Doe/i);
}
});
});

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
/**
* Field types shared by the Hiboutik payload schemas. Hiboutik sends numbers either as JSON numbers or as numeric
* strings, and empty values as "", null or a missing key: the schemas normalize all that, so that the rest of the
* code gets real numbers and strings. Strict on what the sync relies on (identifiers, relations, prices),
* lenient (empty string) on free text.
*/
const trimmed = (value: unknown) => (typeof value === 'string' ? value.trim() : value);
const emptyAsZero = (value: unknown) => {
const v = trimmed(value);
return v == null || v === '' ? 0 : v;
};
/** Identifier of a Hiboutik entity (join key): a positive integer. */
export const hiboutikId = z.preprocess(trimmed, z.coerce.number().int().positive());
/** Reference to another entity: its id, or 0 (also sent as "" or null) when none. */
export const hiboutikRef = z.preprocess(emptyAsZero, z.coerce.number().int().nonnegative());
/** Integer, e.g. a 1/0 flag, a position or a quantity (0 when empty). */
export const hiboutikInt = z.preprocess(emptyAsZero, z.coerce.number().int());
/** Number, e.g. a stock that may be sent as "3.000" (0 when empty). */
export const hiboutikNumber = z.preprocess(emptyAsZero, z.coerce.number());
/** Free text, "" when empty. */
export const hiboutikText = z.preprocess((value) => (value == null ? '' : typeof value === 'number' ? String(value) : value), z.string());
/** Required decimal kept as a string for Prisma `Decimal`, e.g. "13.99". */
export const hiboutikDecimal = z.preprocess(
(value) => (typeof value === 'number' ? String(value) : trimmed(value)),
z.string().regex(/^-?\d+(\.\d+)?$/, 'decimal expected'),
);
/** List that is empty when missing ("", null or no key). */
export const hiboutikList = <T extends z.ZodType>(item: T) =>
z.preprocess((value) => (value == null || value === '' ? [] : value), z.array(item));
/** List that is `undefined` when it is not an array: the caller then keeps the stored data instead of emptying it. */
export const hiboutikOptionalList = <T extends z.ZodType>(item: T) =>
z.preprocess((value) => (Array.isArray(value) ? value : undefined), z.array(item).optional());
/**
* Validates a Hiboutik payload. The error names the faulty fields (path and issue code) but never their values,
* which may be personal data.
*/
export function parseHiboutik<T extends z.ZodType>(schema: T, data: unknown, path: string): z.output<T> {
const result = schema.safeParse(data);
if (result.success) return result.data;
const issues = result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join('.') || '(root)'} (${issue.code})`)
.join(', ');
throw new Error(`Unexpected answer from Hiboutik ${path}: invalid ${issues}`);
}

View File

@@ -1,46 +1,51 @@
/** Product as returned by Hiboutik `GET /products` (archived products included). */
export interface ProductHiboutikDto {
product_id: number;
/** Product name. */
product_model: string;
/** Empty string when the product has no barcode. */
product_barcode: string;
/** Brand id, 0 when none. */
product_brand: number;
/** Supplier id, 0 when none. */
product_supplier: number;
/** Decimal as a string, e.g. "13.99". */
product_price: string;
/** "0.00" when the product has no discount. */
product_discount_price: string;
product_supply_price: string;
/** Category id, 0 when none. */
product_category: number;
/** Displayed on the website: 1 = yes, 0 = no. */
product_display_www: number;
/** Archived: 1 = yes, 0 = no. */
product_arch: number;
products_desc: string;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: string;
/** Per-size overrides of the product's price, discount and barcode. */
product_specific_rules: ProductSpecificRulesHiboutikDto[];
/** Sizes of the product; missing or empty for a product without sizes. */
product_size_details?: ProductSizeDetailsHiboutikDto[];
}
import { z } from 'zod';
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
export interface ProductSpecificRulesHiboutikDto {
size_id: number;
barcode: string;
product_price?: string;
product_discount_price?: string;
}
export const productSpecificRulesHiboutikSchema = z.object({
size_id: hiboutikRef,
product_price: hiboutikText,
product_discount_price: hiboutikText,
});
/** One size of a product (e.g. "S", "M"). */
export interface ProductSizeDetailsHiboutikDto {
size_id: number;
size_name: string;
/** Empty string when the size has no barcode. */
barcode: string;
}
export const productSizeDetailsHiboutikSchema = z.object({
size_id: hiboutikId,
size_name: hiboutikText,
/** Empty when the size has no barcode. */
barcode: hiboutikText,
});
/** Product as returned by Hiboutik `GET /products`, archived products included (only the fields used by the sync). */
export const productHiboutikSchema = z.object({
product_id: hiboutikId,
/** Product name. */
product_model: hiboutikText,
/** Empty when the product has no barcode. */
product_barcode: hiboutikText,
/** Brand id, 0 when none. */
product_brand: hiboutikRef,
/** Supplier id, 0 when none. */
product_supplier: hiboutikRef,
/** e.g. "13.99". */
product_price: hiboutikDecimal,
/** "0.00" when the product has no discount. */
product_discount_price: hiboutikText,
/** Category id, 0 when none. */
product_category: hiboutikRef,
/** Displayed on the website: 1 = yes, 0 = no. */
product_display_www: hiboutikInt,
/** Archived: 1 = yes, 0 = no. */
product_arch: hiboutikInt,
products_desc: hiboutikText,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
updated_at: hiboutikText,
/** Per-size overrides of the product's price, discount and barcode. */
product_specific_rules: hiboutikList(productSpecificRulesHiboutikSchema),
/** Sizes of the product; empty for a product without sizes. */
product_size_details: hiboutikList(productSizeDetailsHiboutikSchema),
});
export type ProductSpecificRulesHiboutikDto = z.output<typeof productSpecificRulesHiboutikSchema>;
export type ProductSizeDetailsHiboutikDto = z.output<typeof productSizeDetailsHiboutikSchema>;
export type ProductHiboutikDto = z.output<typeof productHiboutikSchema>;

View File

@@ -1,40 +1,39 @@
/**
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines.
* Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts,
* totals by tax, customer and store details...).
*/
export interface SaleHiboutikDto {
sale_id: number;
/** Local time, "YYYY-MM-DD HH:mm:ss". */
created_at: string;
/** Local time, "0000-00-00 00:00:00" while the sale is not completed. */
completed_at: string;
store_id: number;
customer_id: number;
currency: string;
/** Decimal as a string, taxes included. */
total: string;
/** Payment type code, e.g. "CB", "ESP", "WEB". */
payment: string;
/** 1 for a quotation. */
quotation: number;
line_items: SaleLineHiboutikDto[];
}
import { z } from 'zod';
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */
export interface SaleLineHiboutikDto {
line_item_id: number;
product_id: number;
/** Line of a sale: a snapshot of the product at sale time. */
export const saleLineHiboutikSchema = z.object({
product_id: hiboutikRef,
/** Hiboutik size id, 0 for a product without sizes. */
product_size: number;
product_size: hiboutikRef,
/** Negative for a returned item. */
quantity: number;
product_model: string;
product_barcode: string;
quantity: hiboutikInt,
product_model: hiboutikText,
product_barcode: hiboutikText,
/** Size name, empty for a product without sizes. */
size_name: string;
/** Unit price before discount, taxes included (decimal as a string). */
item_unit_gross_wo_discount: string;
/** Unit price actually paid, discount deducted, taxes included (decimal as a string). */
item_unit_gross: string;
}
size_name: hiboutikText,
/** Unit price actually paid, discount deducted, taxes included. */
item_unit_gross: hiboutikDecimal,
});
/**
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. Only the fields used by the sync: the payload
* also carries many computed copies (dates split in parts, totals by tax, customer and store details...).
*/
export const saleHiboutikSchema = z.object({
sale_id: hiboutikId,
/** French local time, "YYYY-MM-DD HH:mm:ss". */
created_at: hiboutikText,
/** French local time, "0000-00-00 00:00:00" while the sale is not completed. */
completed_at: hiboutikText,
/** 0 for an anonymous sale. */
customer_id: hiboutikRef,
/** Taxes included. */
total: hiboutikDecimal,
/** 1 for a quotation. */
quotation: hiboutikInt,
line_items: hiboutikList(saleLineHiboutikSchema),
});
export type SaleLineHiboutikDto = z.output<typeof saleLineHiboutikSchema>;
export type SaleHiboutikDto = z.output<typeof saleHiboutikSchema>;

View File

@@ -1,10 +1,13 @@
import { z } from 'zod';
import { hiboutikId, hiboutikNumber, hiboutikRef } from './hiboutik.schema';
/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */
export interface StockHiboutikDto {
product_id: number;
export const stockHiboutikSchema = z.object({
product_id: hiboutikId,
/** Size id, 0 for a product without sizes. */
product_size: number;
/** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
stock_available: string;
product_barcode: string;
product_supplier_reference: string;
}
product_size: hiboutikRef,
/** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
stock_available: hiboutikNumber,
});
export type StockHiboutikDto = z.output<typeof stockHiboutikSchema>;

View File

@@ -1,7 +1,12 @@
/** Supplier as returned by Hiboutik `GET /suppliers`. */
export interface SupplierHiboutikDto {
supplier_id: number;
supplier_name: string;
import { z } from 'zod';
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */
export const supplierHiboutikSchema = z.object({
supplier_id: hiboutikId,
supplier_name: hiboutikText,
/** 1 = enabled, 0 = disabled. */
supplier_enabled: number;
}
supplier_enabled: hiboutikInt,
});
export type SupplierHiboutikDto = z.output<typeof supplierHiboutikSchema>;