From ad8e39f63ca94f60993ae8f3f712f1588412459a Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 7 Nov 2026 10:48:49 +0100 Subject: [PATCH] feat(sync): add company details to customer addresses Addresses with a company are completed through GET /customers_addresses/:id (tax number, company number, legal status), which the customer payload does not embed. Addresses without company get these fields set to null; a failed call keeps the stored values. Co-Authored-By: Claude Opus 5.5 --- prisma/schema.prisma | 30 +++++++------ src/hiboutik/customer.hiboutik.repository.ts | 17 +++++++- .../customerAddress.converter.spec.ts | 30 +++++++++++++ .../converter/customerAddress.converter.ts | 25 +++++++++-- .../hiboutikDTO/customer.hiboutik.dto.ts | 12 ++++++ .../customer.hiboutik.service.spec.ts | 43 ++++++++++++++++++- .../hiboutik/customer.hiboutik.service.ts | 38 +++++++++++++--- 7 files changed, 172 insertions(+), 23 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1decaeb..922e85e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,19 +151,25 @@ model CustomerAddress { id String @id @default(uuid()) updatedAt DateTime @updatedAt - address String - city String - company String? - country String - firstName String - hiboutikId Int @unique - isDefault Boolean @default(false) - lastName String + address String + city String + company String? + /// Company registration number (SIRET...), only for an address with a company + companyNumber String? + country String + firstName String + hiboutikId Int @unique + isDefault Boolean @default(false) + lastName String + /// Company legal status (SAS, SARL...), only for an address with a company + legalStatus String? /// Free complement (building, floor, door code...) - other String? - phone String? - state String? - zipCode String + other String? + phone String? + state String? + /// Company VAT number, only for an address with a company + taxNumber String? + zipCode String customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customerId String diff --git a/src/hiboutik/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts index 95d0dc3..50179dd 100644 --- a/src/hiboutik/customer.hiboutik.repository.ts +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { CustomerHiboutikDto, FullCustomerHiboutikDto } from '../models/hiboutikDTO/customer.hiboutik.dto'; +import { + CustomerAddressDetailsHiboutikDto, + CustomerHiboutikDto, + FullCustomerHiboutikDto, +} from '../models/hiboutikDTO/customer.hiboutik.dto'; /** Reads customers from the Hiboutik API. */ @Injectable() @@ -22,4 +26,15 @@ export class CustomerHiboutikRepository { if (!customer) throw new Error(`Hiboutik customer ${id} not found`); return customer; } + + /** + * Address with its company details (Hiboutik answers with a one-element array). + * @throws if the address does not exist. + */ + async getAddressById(id: number): Promise { + const data = await this.hiboutikClient.get('/customers_addresses/' + String(id)); + const address = data[0]; + if (!address) throw new Error(`Hiboutik address ${id} not found`); + return address; + } } diff --git a/src/models/converter/customerAddress.converter.spec.ts b/src/models/converter/customerAddress.converter.spec.ts index 75aa57b..eee113f 100644 --- a/src/models/converter/customerAddress.converter.spec.ts +++ b/src/models/converter/customerAddress.converter.spec.ts @@ -56,4 +56,34 @@ describe('CustomerAddressConverter', () => { expect(address.other).toBeNull(); expect(address.firstName).toBe(''); }); + + describe('company details', () => { + const details = { tax_number: ' FR123 ', company_number: '12345678900012', legal_status: 'SAS', updated_at: '2026-11-01 10:00:00' }; + + it('maps them when fetched for an address with a company', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details }); + expect(address.taxNumber).toBe('FR123'); + expect(address.companyNumber).toBe('12345678900012'); + expect(address.legalStatus).toBe('SAS'); + }); + + it('erases empty fetched values (null)', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details, tax_number: '' }); + expect(address.taxNumber).toBeNull(); + }); + + it('keeps the stored ones when not fetched (undefined)', () => { + const address = converter.fromHiboutik(hiboutikAddress()); + expect(address.taxNumber).toBeUndefined(); + expect(address.companyNumber).toBeUndefined(); + expect(address.legalStatus).toBeUndefined(); + }); + + it('erases them when the address has no company (null)', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress({ company: '' }), ...details }); + expect(address.taxNumber).toBeNull(); + expect(address.companyNumber).toBeNull(); + expect(address.legalStatus).toBeNull(); + }); + }); }); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts index dcd52c9..ab50c37 100644 --- a/src/models/converter/customerAddress.converter.ts +++ b/src/models/converter/customerAddress.converter.ts @@ -1,27 +1,44 @@ import { Injectable } from '@nestjs/common'; import { Converter } from './converter'; import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress'; -import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; +import { CustomerAddressDetailsHiboutikDto, CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; /** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */ const optional = (value: string | null | undefined): string | null => value?.trim() || null; const required = (value: string | null | undefined): string => value?.trim() ?? ''; +/** + * Maps an address. Company details (tax/company number, legal status) are: + * - `null` when the address has no company, + * - mapped when `dto` comes from `GET /customers_addresses/:id` ({@link CustomerAddressDetailsHiboutikDto}), + * - `undefined` (stored values kept) when the details were not fetched. + */ @Injectable() -export class CustomerAddressConverter extends Converter { - fromHiboutik(dto: CustomerAddressHiboutikDto): CustomerAddressCreateInput { +export class CustomerAddressConverter extends Converter< + CustomerAddressCreateInput, + CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto +> { + fromHiboutik(dto: CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto): CustomerAddressCreateInput { + const company = optional(dto.company); + const hasDetails = 'tax_number' in dto; + const companyDetail = (value: string | null | undefined) => (!company ? null : hasDetails ? optional(value) : undefined); + const details = dto as Partial; + return { address: required(dto.address), city: required(dto.city), - company: optional(dto.company), + company, + companyNumber: companyDetail(details.company_number), country: required(dto.country), firstName: required(dto.first_name), hiboutikId: Number(dto.address_id), isDefault: Number(dto.default) === 1, lastName: required(dto.last_name), + legalStatus: companyDetail(details.legal_status), other: optional(dto.other), phone: optional(dto.phone), state: optional(dto.state), + taxNumber: companyDetail(details.tax_number), zipCode: required(dto.zip_code), customer: { connect: { hiboutikId: Number(dto.customers_id) } }, }; diff --git a/src/models/hiboutikDTO/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts index f945d69..ccb42b6 100644 --- a/src/models/hiboutikDTO/customer.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -60,6 +60,18 @@ export interface CustomerAddressHiboutikDto { other: string; } +/** + * 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. + */ +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; +} + /** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */ export interface CustomerSaleHiboutikDto { sale_id: number; diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index c1508b9..afb09ad 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import { CustomerHiboutikService } from './customer.hiboutik.service'; describe('CustomerHiboutikService', () => { - const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() }; + const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: jest.fn() }; const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; const customerConverter = { fromHiboutik: jest.fn() }; const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; @@ -95,6 +95,47 @@ describe('CustomerHiboutikService', () => { expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled(); }); + it('fetches the company details of addresses with a company only', async () => { + customerHiboutikRepository.getCustomerById.mockResolvedValue({ + customers_id: 1, + addresses: [ + { address_id: 10, company: 'ACME' }, + { address_id: 11, company: ' ' }, + ], + }); + customerHiboutikRepository.getAddressById.mockResolvedValue({ + address_id: 10, + tax_number: 'FR123', + company_number: '123', + legal_status: 'SAS', + updated_at: '2026-11-01 10:00:00', + }); + + await service.syncCustomers(); + + expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledTimes(1); + expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledWith(10); + expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ + address_id: 10, + company: 'ACME', + customers_id: 1, + tax_number: 'FR123', + company_number: '123', + legal_status: 'SAS', + updated_at: '2026-11-01 10:00:00', + }); + }); + + it('still syncs an address whose company details cannot be fetched', async () => { + customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [{ address_id: 10, company: 'ACME' }] }); + customerHiboutikRepository.getAddressById.mockRejectedValue(new Error('boom')); + + await service.syncCustomers(); + + expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ address_id: 10, company: 'ACME', customers_id: 1 }); + expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(1); + }); + it('keeps syncing the other addresses when one fails', async () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 909183e..6e5cdf0 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -4,7 +4,11 @@ import { CustomerDatabaseRepository } from '../../database/customer.database.rep import { CustomerConverter } from '../../models/converter/customer.converter'; import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository'; import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter'; -import { FullCustomerHiboutikDto } from '../../models/hiboutikDTO/customer.hiboutik.dto'; +import { + CustomerAddressDetailsHiboutikDto, + CustomerAddressHiboutikDto, + FullCustomerHiboutikDto, +} from '../../models/hiboutikDTO/customer.hiboutik.dto'; import { forEachSafely } from '../../utils/sync'; @Injectable() @@ -72,12 +76,36 @@ export class CustomerHiboutikService { return forEachSafely( hiboutikCustomer.addresses, // Linked to the parent customer, whatever the embedded customers_id says - (dto) => - this.customerAddressDatabaseRepository.upsert( - this.customerAddressConverter.fromHiboutik({ ...dto, customers_id: hiboutikCustomer.customers_id }), - ), + async (dto) => { + const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto; + await this.customerAddressDatabaseRepository.upsert( + this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }), + ); + }, this.logger, (dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`, ); } + + /** + * Completes an embedded address with its company details (one API call). On failure, the embedded address is returned + * as is, so the address is still synced and its stored company details are kept. + */ + private async withCompanyDetails( + dto: CustomerAddressHiboutikDto, + ): Promise { + try { + const details = await this.customerHiboutikRepository.getAddressById(dto.address_id); + return { + ...dto, + tax_number: details.tax_number, + company_number: details.company_number, + legal_status: details.legal_status, + updated_at: details.updated_at, + }; + } catch (error) { + this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`); + return dto; + } + } }