feat(sync): synchronize customer adresses and orders from Hiboutik sales #1
@@ -151,19 +151,25 @@ model CustomerAddress {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
address String
|
address String
|
||||||
city String
|
city String
|
||||||
company String?
|
company String?
|
||||||
country String
|
/// Company registration number (SIRET...), only for an address with a company
|
||||||
firstName String
|
companyNumber String?
|
||||||
hiboutikId Int @unique
|
country String
|
||||||
isDefault Boolean @default(false)
|
firstName String
|
||||||
lastName 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...)
|
/// Free complement (building, floor, door code...)
|
||||||
other String?
|
other String?
|
||||||
phone String?
|
phone String?
|
||||||
state String?
|
state String?
|
||||||
zipCode String
|
/// Company VAT number, only for an address with a company
|
||||||
|
taxNumber String?
|
||||||
|
zipCode String
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
customerId String
|
customerId String
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
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. */
|
/** Reads customers from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -22,4 +26,15 @@ export class CustomerHiboutikRepository {
|
|||||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||||
return customer;
|
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<CustomerAddressDetailsHiboutikDto> {
|
||||||
|
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
||||||
|
const address = data[0];
|
||||||
|
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,34 @@ describe('CustomerAddressConverter', () => {
|
|||||||
expect(address.other).toBeNull();
|
expect(address.other).toBeNull();
|
||||||
expect(address.firstName).toBe('');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Converter } from './converter';
|
import { Converter } from './converter';
|
||||||
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
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. */
|
/** 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 optional = (value: string | null | undefined): string | null => value?.trim() || null;
|
||||||
const required = (value: string | null | undefined): string => value?.trim() ?? '';
|
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()
|
@Injectable()
|
||||||
export class CustomerAddressConverter extends Converter<CustomerAddressCreateInput, CustomerAddressHiboutikDto> {
|
export class CustomerAddressConverter extends Converter<
|
||||||
fromHiboutik(dto: CustomerAddressHiboutikDto): CustomerAddressCreateInput {
|
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<CustomerAddressDetailsHiboutikDto>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
address: required(dto.address),
|
address: required(dto.address),
|
||||||
city: required(dto.city),
|
city: required(dto.city),
|
||||||
company: optional(dto.company),
|
company,
|
||||||
|
companyNumber: companyDetail(details.company_number),
|
||||||
country: required(dto.country),
|
country: required(dto.country),
|
||||||
firstName: required(dto.first_name),
|
firstName: required(dto.first_name),
|
||||||
hiboutikId: Number(dto.address_id),
|
hiboutikId: Number(dto.address_id),
|
||||||
isDefault: Number(dto.default) === 1,
|
isDefault: Number(dto.default) === 1,
|
||||||
lastName: required(dto.last_name),
|
lastName: required(dto.last_name),
|
||||||
|
legalStatus: companyDetail(details.legal_status),
|
||||||
other: optional(dto.other),
|
other: optional(dto.other),
|
||||||
phone: optional(dto.phone),
|
phone: optional(dto.phone),
|
||||||
state: optional(dto.state),
|
state: optional(dto.state),
|
||||||
|
taxNumber: companyDetail(details.tax_number),
|
||||||
zipCode: required(dto.zip_code),
|
zipCode: required(dto.zip_code),
|
||||||
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ export interface CustomerAddressHiboutikDto {
|
|||||||
other: string;
|
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`. */
|
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
|
||||||
export interface CustomerSaleHiboutikDto {
|
export interface CustomerSaleHiboutikDto {
|
||||||
sale_id: number;
|
sale_id: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||||
|
|
||||||
describe('CustomerHiboutikService', () => {
|
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 customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
const customerConverter = { fromHiboutik: jest.fn() };
|
const customerConverter = { fromHiboutik: jest.fn() };
|
||||||
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
@@ -95,6 +95,47 @@ describe('CustomerHiboutikService', () => {
|
|||||||
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
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 () => {
|
it('keeps syncing the other addresses when one fails', async () => {
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
customers_id: 1,
|
customers_id: 1,
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { CustomerDatabaseRepository } from '../../database/customer.database.rep
|
|||||||
import { CustomerConverter } from '../../models/converter/customer.converter';
|
import { CustomerConverter } from '../../models/converter/customer.converter';
|
||||||
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
|
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
|
||||||
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
|
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';
|
import { forEachSafely } from '../../utils/sync';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -72,12 +76,36 @@ export class CustomerHiboutikService {
|
|||||||
return forEachSafely(
|
return forEachSafely(
|
||||||
hiboutikCustomer.addresses,
|
hiboutikCustomer.addresses,
|
||||||
// Linked to the parent customer, whatever the embedded customers_id says
|
// Linked to the parent customer, whatever the embedded customers_id says
|
||||||
(dto) =>
|
async (dto) => {
|
||||||
this.customerAddressDatabaseRepository.upsert(
|
const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto;
|
||||||
this.customerAddressConverter.fromHiboutik({ ...dto, customers_id: hiboutikCustomer.customers_id }),
|
await this.customerAddressDatabaseRepository.upsert(
|
||||||
),
|
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
||||||
|
);
|
||||||
|
},
|
||||||
this.logger,
|
this.logger,
|
||||||
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
(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<CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user